From f65e098b9d4f3de99de43d50ce4baa55a89ce7de Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 15 Apr 2026 00:18:21 +0200 Subject: [PATCH] refactor: make ts scripts more readable --- static/js/anime.ts | 107 +++++++++-------- static/js/auth.ts | 2 + static/js/discover.ts | 84 +++++++------ static/js/search.ts | 274 ++++++++++++++++++++++++------------------ static/js/timezone.ts | 10 +- 5 files changed, 269 insertions(+), 208 deletions(-) diff --git a/static/js/anime.ts b/static/js/anime.ts index 3693c09..996fe12 100644 --- a/static/js/anime.ts +++ b/static/js/anime.ts @@ -1,62 +1,71 @@ -((): void => { - const parseClassList = (value: string | null): string[] => { - if (!value) { - return [] - } +export {} - return value - .split(' ') - .map((entry: string): string => entry.trim()) - .filter((entry: string): boolean => entry.length > 0) +const parseClassList = (value: string | null): string[] => { + if (!value) { + return [] } - 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')) + return value + .split(' ') + .map((entry: string): string => entry.trim()) + .filter((entry: string): boolean => entry.length > 0) +} - if (isOpen) { - menu.classList.remove(...closedClasses) - menu.classList.add(...openClasses) - return - } +const setDropdownMenuState = (menu: HTMLElement, isOpen: boolean): void => { + const openClasses = parseClassList(menu.getAttribute('data-dropdown-open-classes')) + const closedClasses = parseClassList(menu.getAttribute('data-dropdown-closed-classes')) - menu.classList.remove(...openClasses) - menu.classList.add(...closedClasses) + if (isOpen) { + menu.classList.remove(...closedClasses) + menu.classList.add(...openClasses) + return } - const toggleDropdown = (): void => { - const dropdown = document.getElementById('watchlist-dropdown') - if (!dropdown) { - return - } + menu.classList.remove(...openClasses) + menu.classList.add(...closedClasses) +} - const isOpen = !dropdown.classList.contains('open') - dropdown.classList.toggle('open', isOpen) - const menu = dropdown.querySelector('[data-dropdown-menu]') - if (menu instanceof HTMLElement) { - setMenuState(menu, isOpen) - } +const setWatchlistDropdownState = (isOpen: boolean): void => { + const dropdown = document.getElementById('watchlist-dropdown') + if (!dropdown) { + return } - ;(window as Window & { toggleDropdown?: () => void }).toggleDropdown = toggleDropdown + dropdown.classList.toggle('open', isOpen) + const menu = dropdown.querySelector('[data-dropdown-menu]') + if (menu instanceof HTMLElement) { + setDropdownMenuState(menu, isOpen) + } +} - document.addEventListener('click', (event: MouseEvent): void => { - const dropdown = document.getElementById('watchlist-dropdown') - if (!dropdown) { - return - } +const toggleWatchlistDropdown = (): void => { + const dropdown = document.getElementById('watchlist-dropdown') + if (!dropdown) { + return + } - const target = event.target - if (!(target instanceof Node)) { - return - } + setWatchlistDropdownState(!dropdown.classList.contains('open')) +} - if (!dropdown.contains(target)) { - dropdown.classList.remove('open') - const menu = dropdown.querySelector('[data-dropdown-menu]') - if (menu instanceof HTMLElement) { - setMenuState(menu, false) - } - } - }) -})() +const closeDropdownOnOutsideClick = (event: MouseEvent): void => { + const dropdown = document.getElementById('watchlist-dropdown') + if (!dropdown) { + return + } + + const target = event.target + if (!(target instanceof Node)) { + return + } + + if (!dropdown.contains(target)) { + setWatchlistDropdownState(false) + } +} + +const initWatchlistDropdown = (): void => { + ;(window as Window & { toggleDropdown?: () => void }).toggleDropdown = toggleWatchlistDropdown + document.addEventListener('click', closeDropdownOnOutsideClick) +} + +initWatchlistDropdown() diff --git a/static/js/auth.ts b/static/js/auth.ts index dab5040..e642b71 100644 --- a/static/js/auth.ts +++ b/static/js/auth.ts @@ -1,3 +1,5 @@ +export {} + function copyRecoveryKey(keyElementId: string, feedbackElementId: string): void { const keyElement = document.getElementById(keyElementId) const feedbackElement = document.getElementById(feedbackElementId) diff --git a/static/js/discover.ts b/static/js/discover.ts index 93ece01..e12c804 100644 --- a/static/js/discover.ts +++ b/static/js/discover.ts @@ -1,46 +1,52 @@ -((): void => { - const parseClassList = (value: string | null): string[] => { - if (!value) { - return [] - } +export {} - return value - .split(' ') - .map((entry: string): string => entry.trim()) - .filter((entry: string): boolean => entry.length > 0) +const parseClassList = (value: string | null): string[] => { + if (!value) { + return [] } - const setActiveTab = (clickedTab: Element): void => { - const group = clickedTab.closest('[data-tab-group="discover"]') - if (!group) { - return - } + return value + .split(' ') + .map((entry: string): string => entry.trim()) + .filter((entry: string): boolean => entry.length > 0) +} - const triggers = group.querySelectorAll('[data-tab-trigger]') - triggers.forEach((tab: Element): void => { - const activeClasses = parseClassList(tab.getAttribute('data-tab-active-classes')) - const inactiveClasses = parseClassList(tab.getAttribute('data-tab-inactive-classes')) - tab.classList.remove(...activeClasses) - tab.classList.add(...inactiveClasses) - }) - - const activeClasses = parseClassList(clickedTab.getAttribute('data-tab-active-classes')) - const inactiveClasses = parseClassList(clickedTab.getAttribute('data-tab-inactive-classes')) - clickedTab.classList.remove(...inactiveClasses) - clickedTab.classList.add(...activeClasses) +const setActiveDiscoverTab = (clickedTab: Element): void => { + const group = clickedTab.closest('[data-tab-group="discover"]') + if (!group) { + return } - document.addEventListener('click', (event: MouseEvent): void => { - const target = event.target - if (!(target instanceof Element)) { - return - } - - const trigger = target.closest('[data-tab-trigger]') - if (!trigger) { - return - } - - setActiveTab(trigger) + const triggers = group.querySelectorAll('[data-tab-trigger]') + triggers.forEach((tab: Element): void => { + const activeClasses = parseClassList(tab.getAttribute('data-tab-active-classes')) + const inactiveClasses = parseClassList(tab.getAttribute('data-tab-inactive-classes')) + tab.classList.remove(...activeClasses) + tab.classList.add(...inactiveClasses) }) -})() + + const activeClasses = parseClassList(clickedTab.getAttribute('data-tab-active-classes')) + const inactiveClasses = parseClassList(clickedTab.getAttribute('data-tab-inactive-classes')) + clickedTab.classList.remove(...inactiveClasses) + clickedTab.classList.add(...activeClasses) +} + +const onDiscoverTabClick = (event: MouseEvent): void => { + const target = event.target + if (!(target instanceof Element)) { + return + } + + const trigger = target.closest('[data-tab-trigger]') + if (!trigger) { + return + } + + setActiveDiscoverTab(trigger) +} + +const initDiscoverTabs = (): void => { + document.addEventListener('click', onDiscoverTabClick) +} + +initDiscoverTabs() diff --git a/static/js/search.ts b/static/js/search.ts index e0123f5..c76fb77 100644 --- a/static/js/search.ts +++ b/static/js/search.ts @@ -1,127 +1,167 @@ -((): void => { - const globalWindow = window as Window & { searchInitialized?: boolean } +export {} + +type QuickSearchResult = { + id?: number + image?: string + title?: string + type?: string +} + +const globalWindow = window as Window & { searchInitialized?: boolean } + +let searchTimeout: number | undefined +const searchInput = document.getElementById('search-input') as HTMLInputElement | null +const searchDropdown = document.querySelector('[data-search-results-container]') as HTMLElement | null + +const isSafeImageUrl = (rawUrl?: string): boolean => { + if (!rawUrl || typeof rawUrl !== 'string') { + return false + } + + try { + const parsed = new URL(rawUrl, window.location.origin) + return parsed.protocol === 'https:' || parsed.protocol === 'http:' + } catch { + return false + } +} + +const clearSearchResults = (): void => { + if (!searchDropdown) { + return + } + + searchDropdown.replaceChildren() +} + +const buildSearchResultItem = (result: QuickSearchResult): HTMLAnchorElement => { + const item = document.createElement('a') + 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 || ''))) + + if (isSafeImageUrl(result.image)) { + const img = document.createElement('img') + img.className = 'aspect-[2/3] w-[42px] shrink-0 object-cover bg-[var(--surface-thumb)]' + img.setAttribute('src', result.image || '') + img.setAttribute('alt', String(result.title || '')) + item.appendChild(img) + } else { + const noImage = document.createElement('div') + noImage.className = 'aspect-[2/3] w-[42px] shrink-0 bg-[var(--surface-thumb)] text-[0] text-transparent' + noImage.textContent = 'no image' + item.appendChild(noImage) + } + + const info = document.createElement('div') + info.className = 'grid min-w-0 gap-px' + + const itemTitle = document.createElement('div') + itemTitle.className = 'line-clamp-1 text-[0.86rem] leading-[1.3] text-[var(--text)]' + itemTitle.textContent = String(result.title || '') + info.appendChild(itemTitle) + + const itemType = document.createElement('div') + itemType.className = 'text-[0.67rem] text-[var(--text-faint)]' + itemType.textContent = String(result.type || '') + info.appendChild(itemType) + + item.appendChild(info) + return item +} + +const renderQuickSearchResults = (query: string, results: QuickSearchResult[]): void => { + if (!searchDropdown) { + return + } + + if (!results || results.length === 0) { + clearSearchResults() + return + } + + const searchResults = document.createElement('div') + searchResults.className = 'grid' + + const title = document.createElement('div') + title.className = 'px-3 py-2 text-[0.68rem] text-[var(--text-faint)]' + title.textContent = 'Anime' + searchResults.appendChild(title) + + results.forEach((result: QuickSearchResult): void => { + searchResults.appendChild(buildSearchResultItem(result)) + }) + + const viewAll = document.createElement('a') + 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.textContent = 'View all results for ' + query + searchResults.appendChild(viewAll) + + searchDropdown.replaceChildren(searchResults) +} + +const fetchAndRenderQuickSearch = (query: string): void => { + fetch('/api/search-quick?q=' + encodeURIComponent(query)) + .then((res: Response) => res.json()) + .then((results: QuickSearchResult[]): void => { + renderQuickSearchResults(query, results) + }) + .catch((err: unknown): void => { + console.error('Search error:', err) + }) +} + +const onSearchInput = (event: Event): void => { + if (searchTimeout) { + window.clearTimeout(searchTimeout) + } + + const target = event.target + if (!(target instanceof HTMLInputElement)) { + return + } + + const query = target.value.trim() + if (query.length < 2) { + clearSearchResults() + return + } + + searchTimeout = window.setTimeout((): void => { + fetchAndRenderQuickSearch(query) + }, 300) +} + +const onSearchBlur = (): void => { + window.setTimeout((): void => { + clearSearchResults() + }, 200) +} + +const onDocumentClick = (event: MouseEvent): void => { + const target = event.target + if (!(target instanceof Element)) { + return + } + + if (!target.closest('[data-search-root]')) { + clearSearchResults() + } +} + +const initQuickSearch = (): void => { if (globalWindow.searchInitialized) { return } globalWindow.searchInitialized = true - let searchTimeout: number | undefined - const searchInput = document.getElementById('search-input') as HTMLInputElement | null - const searchDropdown = document.querySelector('[data-search-results-container]') as HTMLElement | null - if (!searchInput || !searchDropdown) { return } - searchInput.addEventListener('input', (event: Event): void => { - if (searchTimeout) { - window.clearTimeout(searchTimeout) - } + searchInput.addEventListener('input', onSearchInput) + searchInput.addEventListener('blur', onSearchBlur) + document.addEventListener('click', onDocumentClick) +} - const target = event.target - if (!(target instanceof HTMLInputElement)) { - return - } - - const query = target.value.trim() - if (query.length < 2) { - searchDropdown.replaceChildren() - return - } - - searchTimeout = window.setTimeout((): void => { - fetch('/api/search-quick?q=' + encodeURIComponent(query)) - .then((res: Response) => res.json()) - .then((results: Array<{ id?: number; image?: string; title?: string; type?: string }>): void => { - if (!results || results.length === 0) { - searchDropdown.replaceChildren() - return - } - - const searchResults = document.createElement('div') - searchResults.className = 'grid' - - const title = document.createElement('div') - title.className = 'px-3 py-2 text-[0.68rem] text-[var(--text-faint)]' - title.textContent = 'Anime' - searchResults.appendChild(title) - - results.forEach((result): void => { - const item = document.createElement('a') - 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 || ''))) - - if (isSafeImageUrl(result.image)) { - const img = document.createElement('img') - img.className = 'aspect-[2/3] w-[42px] shrink-0 object-cover bg-[var(--surface-thumb)]' - img.setAttribute('src', result.image || '') - img.setAttribute('alt', String(result.title || '')) - item.appendChild(img) - } else { - const noImage = document.createElement('div') - noImage.className = 'aspect-[2/3] w-[42px] shrink-0 bg-[var(--surface-thumb)] text-[0] text-transparent' - noImage.textContent = 'no image' - item.appendChild(noImage) - } - - const info = document.createElement('div') - info.className = 'grid min-w-0 gap-px' - - const itemTitle = document.createElement('div') - itemTitle.className = 'line-clamp-1 text-[0.86rem] leading-[1.3] text-[var(--text)]' - itemTitle.textContent = String(result.title || '') - info.appendChild(itemTitle) - - const itemType = document.createElement('div') - itemType.className = 'text-[0.67rem] text-[var(--text-faint)]' - itemType.textContent = String(result.type || '') - info.appendChild(itemType) - - item.appendChild(info) - searchResults.appendChild(item) - }) - - const viewAll = document.createElement('a') - 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.textContent = 'View all results for ' + query - searchResults.appendChild(viewAll) - - searchDropdown.replaceChildren(searchResults) - }) - .catch((err: unknown): void => { - console.error('Search error:', err) - }) - }, 300) - }) - - searchInput.addEventListener('blur', (): void => { - window.setTimeout((): void => { - searchDropdown.replaceChildren() - }, 200) - }) - - document.addEventListener('click', (event: MouseEvent): void => { - const target = event.target - if (!(target instanceof Element)) { - return - } - - if (!target.closest('[data-search-root]')) { - searchDropdown.replaceChildren() - } - }) - - function isSafeImageUrl(rawUrl?: string): boolean { - if (!rawUrl || typeof rawUrl !== 'string') { - return false - } - - try { - const parsed = new URL(rawUrl, window.location.origin) - return parsed.protocol === 'https:' || parsed.protocol === 'http:' - } catch { - return false - } - } -})() +initQuickSearch() diff --git a/static/js/timezone.ts b/static/js/timezone.ts index 19ef860..1b8158c 100644 --- a/static/js/timezone.ts +++ b/static/js/timezone.ts @@ -1,5 +1,6 @@ -((): void => { - const jstOffsetMinutes = 9 * 60 +export {} + +const jstOffsetMinutes = 9 * 60 type ParsedBroadcast = { day: string @@ -241,6 +242,9 @@ nodes.forEach((node: Element): void => updateNode(node, localOffsetMinutes)) } +const initTimezoneConversion = (): void => { document.addEventListener('DOMContentLoaded', updateAll) document.body.addEventListener('htmx:afterSwap', updateAll) -})() +} + +initTimezoneConversion()