refactor: switch ui scripts to data hooks

This commit is contained in:
2026-04-15 00:10:20 +02:00
parent 0a5f64c604
commit 29e49b9fcc
9 changed files with 111 additions and 45 deletions

View File

@@ -196,7 +196,7 @@ func joinStreamingNames(anime jikan.Anime) string {
templ WatchlistDropdown(animeID int, animeTitle string, animeTitleEnglish string, animeTitleJapanese string, animeImage string, currentStatus string, airing bool) { templ WatchlistDropdown(animeID int, animeTitle string, animeTitleEnglish string, animeTitleJapanese string, animeImage string, currentStatus string, airing bool) {
<div class="relative inline-block" id="watchlist-dropdown"> <div class="relative inline-block" id="watchlist-dropdown">
<button class="inline-flex h-8 cursor-pointer items-center gap-2 bg-[var(--panel-soft)] px-2 text-[0.8rem] text-[var(--text)]" onclick="toggleDropdown()"> <button class="inline-flex h-8 cursor-pointer items-center gap-2 bg-[var(--panel-soft)] px-2 text-[0.8rem] text-[var(--text)]" onclick="toggleDropdown()" data-dropdown-trigger>
if currentStatus != "" { if currentStatus != "" {
{ formatStatus(currentStatus) } { formatStatus(currentStatus) }
} else { } else {
@@ -204,7 +204,7 @@ templ WatchlistDropdown(animeID int, animeTitle string, animeTitleEnglish string
} }
<span class="text-[0.64rem]">▾</span> <span class="text-[0.64rem]">▾</span>
</button> </button>
<div class="invisible absolute left-0 top-[calc(100%+2px)] z-[110] min-w-[210px] bg-[var(--panel)] opacity-0 transition-opacity duration-150" data-dropdown-menu> <div class="invisible absolute left-0 top-[calc(100%+2px)] z-[110] min-w-[210px] bg-[var(--panel)] opacity-0 transition-opacity duration-150" data-dropdown-menu data-dropdown-open-classes="visible opacity-100" data-dropdown-closed-classes="invisible opacity-0">
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "watching", currentStatus, airing) @dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "watching", currentStatus, airing)
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "completed", currentStatus, airing) @dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "completed", currentStatus, airing)
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "on_hold", currentStatus, airing) @dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "on_hold", currentStatus, airing)

View File

@@ -19,6 +19,8 @@ templ Discover() {
hx-target="#discover-content" hx-target="#discover-content"
hx-trigger="click" hx-trigger="click"
data-tab-trigger data-tab-trigger
data-tab-active-classes="bg-[var(--surface-tab-active)] text-[var(--accent)]"
data-tab-inactive-classes="bg-[var(--panel-soft)] text-[var(--text-muted)]"
> >
airing now airing now
</button> </button>
@@ -29,6 +31,8 @@ templ Discover() {
hx-target="#discover-content" hx-target="#discover-content"
hx-trigger="click" hx-trigger="click"
data-tab-trigger data-tab-trigger
data-tab-active-classes="bg-[var(--surface-tab-active)] text-[var(--accent)]"
data-tab-inactive-classes="bg-[var(--panel-soft)] text-[var(--text-muted)]"
> >
upcoming upcoming
</button> </button>

View File

@@ -36,7 +36,7 @@ templ Layout(title string, showHeader bool) {
<div class="relative ml-auto min-w-[240px] w-[min(420px,45vw)] max-[860px]:ml-0 max-[860px]:w-full" data-search-root> <div class="relative ml-auto min-w-[240px] w-[min(420px,45vw)] max-[860px]:ml-0 max-[860px]:w-full" data-search-root>
<form action="/search" method="GET" class="w-full" id="search-form"> <form action="/search" method="GET" class="w-full" id="search-form">
<input type="text" id="search-input" name="q" class="h-[34px] w-full border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 placeholder:text-[var(--text-faint)] focus:border-[var(--surface-search-focus-border)] focus:outline-none" placeholder="Search anime..." autocomplete="off"/> <input type="text" id="search-input" name="q" class="h-[34px] w-full border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 placeholder:text-[var(--text-faint)] focus:border-[var(--surface-search-focus-border)] focus:outline-none" placeholder="Search anime..." autocomplete="off"/>
<div id="search-dropdown" class="absolute inset-x-0 top-[calc(100%+2px)] z-[120] max-h-[min(70vh,560px)] overflow-y-auto bg-[var(--panel)]"></div> <div id="search-dropdown" class="absolute inset-x-0 top-[calc(100%+2px)] z-[120] max-h-[min(70vh,560px)] overflow-y-auto bg-[var(--panel)]" data-search-results-container></div>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -1,15 +1,32 @@
// static/js/anime.ts // static/js/anime.ts
(() => { (() => {
const parseClassList = (value) => {
if (!value) {
return [];
}
return value.split(" ").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
};
const setMenuState = (menu, isOpen) => {
const openClasses = parseClassList(menu.getAttribute("data-dropdown-open-classes"));
const closedClasses = parseClassList(menu.getAttribute("data-dropdown-closed-classes"));
if (isOpen) {
menu.classList.remove(...closedClasses);
menu.classList.add(...openClasses);
return;
}
menu.classList.remove(...openClasses);
menu.classList.add(...closedClasses);
};
const toggleDropdown = () => { const toggleDropdown = () => {
const dropdown = document.getElementById("watchlist-dropdown"); const dropdown = document.getElementById("watchlist-dropdown");
if (!dropdown) { if (!dropdown) {
return; return;
} }
dropdown.classList.toggle("open"); const isOpen = !dropdown.classList.contains("open");
dropdown.classList.toggle("open", isOpen);
const menu = dropdown.querySelector("[data-dropdown-menu]"); const menu = dropdown.querySelector("[data-dropdown-menu]");
if (menu instanceof HTMLElement) { if (menu instanceof HTMLElement) {
menu.classList.toggle("invisible"); setMenuState(menu, isOpen);
menu.classList.toggle("opacity-0");
} }
}; };
window.toggleDropdown = toggleDropdown; window.toggleDropdown = toggleDropdown;
@@ -26,8 +43,7 @@
dropdown.classList.remove("open"); dropdown.classList.remove("open");
const menu = dropdown.querySelector("[data-dropdown-menu]"); const menu = dropdown.querySelector("[data-dropdown-menu]");
if (menu instanceof HTMLElement) { if (menu instanceof HTMLElement) {
menu.classList.add("invisible"); setMenuState(menu, false);
menu.classList.add("opacity-0");
} }
} }
}); });

View File

@@ -1,15 +1,40 @@
((): void => { ((): void => {
const parseClassList = (value: string | null): string[] => {
if (!value) {
return []
}
return value
.split(' ')
.map((entry: string): string => entry.trim())
.filter((entry: string): boolean => entry.length > 0)
}
const setMenuState = (menu: HTMLElement, isOpen: boolean): void => {
const openClasses = parseClassList(menu.getAttribute('data-dropdown-open-classes'))
const closedClasses = parseClassList(menu.getAttribute('data-dropdown-closed-classes'))
if (isOpen) {
menu.classList.remove(...closedClasses)
menu.classList.add(...openClasses)
return
}
menu.classList.remove(...openClasses)
menu.classList.add(...closedClasses)
}
const toggleDropdown = (): void => { const toggleDropdown = (): void => {
const dropdown = document.getElementById('watchlist-dropdown') const dropdown = document.getElementById('watchlist-dropdown')
if (!dropdown) { if (!dropdown) {
return return
} }
dropdown.classList.toggle('open') const isOpen = !dropdown.classList.contains('open')
dropdown.classList.toggle('open', isOpen)
const menu = dropdown.querySelector('[data-dropdown-menu]') const menu = dropdown.querySelector('[data-dropdown-menu]')
if (menu instanceof HTMLElement) { if (menu instanceof HTMLElement) {
menu.classList.toggle('invisible') setMenuState(menu, isOpen)
menu.classList.toggle('opacity-0')
} }
} }
@@ -30,8 +55,7 @@
dropdown.classList.remove('open') dropdown.classList.remove('open')
const menu = dropdown.querySelector('[data-dropdown-menu]') const menu = dropdown.querySelector('[data-dropdown-menu]')
if (menu instanceof HTMLElement) { if (menu instanceof HTMLElement) {
menu.classList.add('invisible') setMenuState(menu, false)
menu.classList.add('opacity-0')
} }
} }
}) })

View File

@@ -1,5 +1,11 @@
// static/js/discover.ts // static/js/discover.ts
(() => { (() => {
const parseClassList = (value) => {
if (!value) {
return [];
}
return value.split(" ").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
};
const setActiveTab = (clickedTab) => { const setActiveTab = (clickedTab) => {
const group = clickedTab.closest('[data-tab-group="discover"]'); const group = clickedTab.closest('[data-tab-group="discover"]');
if (!group) { if (!group) {
@@ -7,13 +13,15 @@
} }
const triggers = group.querySelectorAll("[data-tab-trigger]"); const triggers = group.querySelectorAll("[data-tab-trigger]");
triggers.forEach((tab) => { triggers.forEach((tab) => {
tab.classList.add("tab-trigger"); const activeClasses2 = parseClassList(tab.getAttribute("data-tab-active-classes"));
tab.classList.remove("bg-[var(--surface-tab-active)]", "text-[var(--accent)]"); const inactiveClasses2 = parseClassList(tab.getAttribute("data-tab-inactive-classes"));
tab.classList.add("bg-[var(--panel-soft)]", "text-[var(--text-muted)]"); tab.classList.remove(...activeClasses2);
tab.classList.add(...inactiveClasses2);
}); });
clickedTab.classList.add("tab-trigger"); const activeClasses = parseClassList(clickedTab.getAttribute("data-tab-active-classes"));
clickedTab.classList.remove("bg-[var(--panel-soft)]", "text-[var(--text-muted)]"); const inactiveClasses = parseClassList(clickedTab.getAttribute("data-tab-inactive-classes"));
clickedTab.classList.add("bg-[var(--surface-tab-active)]", "text-[var(--accent)]"); clickedTab.classList.remove(...inactiveClasses);
clickedTab.classList.add(...activeClasses);
}; };
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
const target = event.target; const target = event.target;

View File

@@ -1,4 +1,15 @@
((): void => { ((): void => {
const parseClassList = (value: string | null): string[] => {
if (!value) {
return []
}
return value
.split(' ')
.map((entry: string): string => entry.trim())
.filter((entry: string): boolean => entry.length > 0)
}
const setActiveTab = (clickedTab: Element): void => { const setActiveTab = (clickedTab: Element): void => {
const group = clickedTab.closest('[data-tab-group="discover"]') const group = clickedTab.closest('[data-tab-group="discover"]')
if (!group) { if (!group) {
@@ -7,13 +18,16 @@
const triggers = group.querySelectorAll('[data-tab-trigger]') const triggers = group.querySelectorAll('[data-tab-trigger]')
triggers.forEach((tab: Element): void => { triggers.forEach((tab: Element): void => {
tab.classList.add('tab-trigger') const activeClasses = parseClassList(tab.getAttribute('data-tab-active-classes'))
tab.classList.remove('bg-[var(--surface-tab-active)]', 'text-[var(--accent)]') const inactiveClasses = parseClassList(tab.getAttribute('data-tab-inactive-classes'))
tab.classList.add('bg-[var(--panel-soft)]', 'text-[var(--text-muted)]') tab.classList.remove(...activeClasses)
tab.classList.add(...inactiveClasses)
}) })
clickedTab.classList.add('tab-trigger')
clickedTab.classList.remove('bg-[var(--panel-soft)]', 'text-[var(--text-muted)]') const activeClasses = parseClassList(clickedTab.getAttribute('data-tab-active-classes'))
clickedTab.classList.add('bg-[var(--surface-tab-active)]', 'text-[var(--accent)]') const inactiveClasses = parseClassList(clickedTab.getAttribute('data-tab-inactive-classes'))
clickedTab.classList.remove(...inactiveClasses)
clickedTab.classList.add(...activeClasses)
} }
document.addEventListener('click', (event: MouseEvent): void => { document.addEventListener('click', (event: MouseEvent): void => {

View File

@@ -7,7 +7,7 @@
globalWindow.searchInitialized = true; globalWindow.searchInitialized = true;
let searchTimeout; let searchTimeout;
const searchInput = document.getElementById("search-input"); const searchInput = document.getElementById("search-input");
const searchDropdown = document.getElementById("search-dropdown"); const searchDropdown = document.querySelector("[data-search-results-container]");
if (!searchInput || !searchDropdown) { if (!searchInput || !searchDropdown) {
return; return;
} }
@@ -31,42 +31,42 @@
return; return;
} }
const searchResults = document.createElement("div"); const searchResults = document.createElement("div");
searchResults.className = "search-results"; searchResults.className = "grid";
const title = document.createElement("div"); const title = document.createElement("div");
title.className = "search-results-title"; title.className = "px-3 py-2 text-[0.68rem] text-[var(--text-faint)]";
title.textContent = "Anime"; title.textContent = "Anime";
searchResults.appendChild(title); searchResults.appendChild(title);
results.forEach((result) => { results.forEach((result) => {
const item = document.createElement("a"); const item = document.createElement("a");
item.className = "search-result-item"; item.className = "flex items-start gap-3 px-3 py-2 text-inherit no-underline hover:bg-[var(--panel-soft)] hover:no-underline";
item.setAttribute("href", "/anime/" + encodeURIComponent(String(result.id || ""))); item.setAttribute("href", "/anime/" + encodeURIComponent(String(result.id || "")));
if (isSafeImageUrl(result.image)) { if (isSafeImageUrl(result.image)) {
const img = document.createElement("img"); const img = document.createElement("img");
img.className = "search-result-thumb"; img.className = "aspect-[2/3] w-[42px] shrink-0 object-cover bg-[var(--surface-thumb)]";
img.setAttribute("src", result.image || ""); img.setAttribute("src", result.image || "");
img.setAttribute("alt", String(result.title || "")); img.setAttribute("alt", String(result.title || ""));
item.appendChild(img); item.appendChild(img);
} else { } else {
const noImage = document.createElement("div"); const noImage = document.createElement("div");
noImage.className = "search-result-no-image"; noImage.className = "aspect-[2/3] w-[42px] shrink-0 bg-[var(--surface-thumb)] text-[0] text-transparent";
noImage.textContent = "no image"; noImage.textContent = "no image";
item.appendChild(noImage); item.appendChild(noImage);
} }
const info = document.createElement("div"); const info = document.createElement("div");
info.className = "search-result-info"; info.className = "grid min-w-0 gap-px";
const itemTitle = document.createElement("div"); const itemTitle = document.createElement("div");
itemTitle.className = "search-result-title"; itemTitle.className = "line-clamp-1 text-[0.86rem] leading-[1.3] text-[var(--text)]";
itemTitle.textContent = String(result.title || ""); itemTitle.textContent = String(result.title || "");
info.appendChild(itemTitle); info.appendChild(itemTitle);
const itemType = document.createElement("div"); const itemType = document.createElement("div");
itemType.className = "search-result-type"; itemType.className = "text-[0.67rem] text-[var(--text-faint)]";
itemType.textContent = String(result.type || ""); itemType.textContent = String(result.type || "");
info.appendChild(itemType); info.appendChild(itemType);
item.appendChild(info); item.appendChild(info);
searchResults.appendChild(item); searchResults.appendChild(item);
}); });
const viewAll = document.createElement("a"); const viewAll = document.createElement("a");
viewAll.className = "search-result-view-all"; viewAll.className = "bg-[var(--surface-search-view-all)] px-3 py-2 text-center text-[0.8rem] text-[var(--text-muted)] no-underline hover:bg-[var(--panel-soft)] hover:text-[var(--text)] hover:no-underline";
viewAll.setAttribute("href", "/search?q=" + encodeURIComponent(query)); viewAll.setAttribute("href", "/search?q=" + encodeURIComponent(query));
viewAll.textContent = "View all results for " + query; viewAll.textContent = "View all results for " + query;
searchResults.appendChild(viewAll); searchResults.appendChild(viewAll);

View File

@@ -7,7 +7,7 @@
let searchTimeout: number | undefined let searchTimeout: number | undefined
const searchInput = document.getElementById('search-input') as HTMLInputElement | null const searchInput = document.getElementById('search-input') as HTMLInputElement | null
const searchDropdown = document.getElementById('search-dropdown') const searchDropdown = document.querySelector('[data-search-results-container]') as HTMLElement | null
if (!searchInput || !searchDropdown) { if (!searchInput || !searchDropdown) {
return return
@@ -39,41 +39,41 @@
} }
const searchResults = document.createElement('div') const searchResults = document.createElement('div')
searchResults.className = 'search-results' searchResults.className = 'grid'
const title = document.createElement('div') const title = document.createElement('div')
title.className = 'search-results-title' title.className = 'px-3 py-2 text-[0.68rem] text-[var(--text-faint)]'
title.textContent = 'Anime' title.textContent = 'Anime'
searchResults.appendChild(title) searchResults.appendChild(title)
results.forEach((result): void => { results.forEach((result): void => {
const item = document.createElement('a') const item = document.createElement('a')
item.className = 'search-result-item' item.className = 'flex items-start gap-3 px-3 py-2 text-inherit no-underline hover:bg-[var(--panel-soft)] hover:no-underline'
item.setAttribute('href', '/anime/' + encodeURIComponent(String(result.id || ''))) item.setAttribute('href', '/anime/' + encodeURIComponent(String(result.id || '')))
if (isSafeImageUrl(result.image)) { if (isSafeImageUrl(result.image)) {
const img = document.createElement('img') const img = document.createElement('img')
img.className = 'search-result-thumb' img.className = 'aspect-[2/3] w-[42px] shrink-0 object-cover bg-[var(--surface-thumb)]'
img.setAttribute('src', result.image || '') img.setAttribute('src', result.image || '')
img.setAttribute('alt', String(result.title || '')) img.setAttribute('alt', String(result.title || ''))
item.appendChild(img) item.appendChild(img)
} else { } else {
const noImage = document.createElement('div') const noImage = document.createElement('div')
noImage.className = 'search-result-no-image' noImage.className = 'aspect-[2/3] w-[42px] shrink-0 bg-[var(--surface-thumb)] text-[0] text-transparent'
noImage.textContent = 'no image' noImage.textContent = 'no image'
item.appendChild(noImage) item.appendChild(noImage)
} }
const info = document.createElement('div') const info = document.createElement('div')
info.className = 'search-result-info' info.className = 'grid min-w-0 gap-px'
const itemTitle = document.createElement('div') const itemTitle = document.createElement('div')
itemTitle.className = 'search-result-title' itemTitle.className = 'line-clamp-1 text-[0.86rem] leading-[1.3] text-[var(--text)]'
itemTitle.textContent = String(result.title || '') itemTitle.textContent = String(result.title || '')
info.appendChild(itemTitle) info.appendChild(itemTitle)
const itemType = document.createElement('div') const itemType = document.createElement('div')
itemType.className = 'search-result-type' itemType.className = 'text-[0.67rem] text-[var(--text-faint)]'
itemType.textContent = String(result.type || '') itemType.textContent = String(result.type || '')
info.appendChild(itemType) info.appendChild(itemType)
@@ -82,7 +82,7 @@
}) })
const viewAll = document.createElement('a') const viewAll = document.createElement('a')
viewAll.className = 'search-result-view-all' viewAll.className = 'bg-[var(--surface-search-view-all)] px-3 py-2 text-center text-[0.8rem] text-[var(--text-muted)] no-underline hover:bg-[var(--panel-soft)] hover:text-[var(--text)] hover:no-underline'
viewAll.setAttribute('href', '/search?q=' + encodeURIComponent(query)) viewAll.setAttribute('href', '/search?q=' + encodeURIComponent(query))
viewAll.textContent = 'View all results for ' + query viewAll.textContent = 'View all results for ' + query
searchResults.appendChild(viewAll) searchResults.appendChild(viewAll)