From 6932d4b8d00ec54ec7c2e4cfd48accb397a7e0e9 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Mon, 25 May 2026 01:16:02 +0200 Subject: [PATCH] refactor: extract inline JS to modules --- static/htmx.ts | 71 ++++ static/shell.ts | 94 +++++ static/watchlist.ts | 326 ++++++++++++++++++ templates/anime.gohtml | 2 +- templates/base.gohtml | 210 +---------- templates/browse.gohtml | 2 +- templates/components/anime_card.gohtml | 9 +- templates/components/continue_watching.gohtml | 4 +- templates/components/watchlist_actions.gohtml | 47 ++- templates/watch.gohtml | 42 ++- templates/watchlist.gohtml | 12 +- templates/watchlist_partial.gohtml | 9 +- 12 files changed, 608 insertions(+), 220 deletions(-) create mode 100644 static/htmx.ts create mode 100644 static/shell.ts create mode 100644 static/watchlist.ts diff --git a/static/htmx.ts b/static/htmx.ts new file mode 100644 index 0000000..271462a --- /dev/null +++ b/static/htmx.ts @@ -0,0 +1,71 @@ +export {}; + +type ToastFn = (opts: { message: string; duration?: number }) => void; + +const getToast = (): ToastFn | null => { + const anyWindow = window as unknown as { showToast?: ToastFn }; + return typeof anyWindow.showToast === 'function' ? anyWindow.showToast : null; +}; + +const toast = (message: string): void => { + getToast()?.({ message }); +}; + +const setBusy = (el: Element | null, busy: boolean): void => { + if (!(el instanceof HTMLElement)) return; + el.toggleAttribute('aria-busy', busy); + el.dataset.htmxLoading = busy ? 'true' : 'false'; + + if (el instanceof HTMLButtonElement) { + el.disabled = busy; + } + + if (busy) { + el.dataset.htmxBusy = 'true'; + return; + } + + delete el.dataset.htmxBusy; +}; + +const getTriggerFromHtmxEvent = (event: Event): Element | null => { + const detail = event as unknown as { detail?: { elt?: Element } }; + return detail.detail?.elt ?? null; +}; + +const onReady = (fn: () => void): void => { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', fn, { once: true }); + return; + } + + fn(); +}; + +onReady(() => { + document.addEventListener('htmx:beforeRequest', event => { + setBusy(getTriggerFromHtmxEvent(event), true); + }); + + document.addEventListener('htmx:afterRequest', event => { + setBusy(getTriggerFromHtmxEvent(event), false); + + const remaining = document.querySelectorAll('.continue-watching-item').length; + if (remaining !== 0) return; + + const section = document.getElementById('continue-watching-section'); + section?.remove(); + }); + + document.addEventListener('htmx:responseError', () => { + toast('Something went wrong'); + }); + + document.addEventListener('htmx:sendError', () => { + toast('Network error'); + }); + + document.addEventListener('htmx:timeout', () => { + toast('Request timed out'); + }); +}); diff --git a/static/shell.ts b/static/shell.ts new file mode 100644 index 0000000..1b66d9a --- /dev/null +++ b/static/shell.ts @@ -0,0 +1,94 @@ +export {}; + +const onReady = (fn: () => void): void => { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', fn, { once: true }); + return; + } + + fn(); +}; + +const isMobileViewport = (): boolean => window.matchMedia('(max-width: 1023px)').matches; + +const initSidebarTransitions = (): void => { + requestAnimationFrame(() => { + document.documentElement.classList.add('sidebar-ready'); + }); +}; + +const initMobileMenu = (): void => { + const menu = document.getElementById('mobile-menu'); + const backdrop = document.getElementById('mobile-menu-backdrop'); + const toggle = document.querySelector('[data-mobile-menu-toggle]'); + + if (!(menu instanceof HTMLElement)) return; + if (!(backdrop instanceof HTMLElement)) return; + if (!(toggle instanceof HTMLElement)) return; + + const body = document.body; + let lastFocused: HTMLElement | null = null; + + const setOpen = (nextOpen: boolean): void => { + menu.dataset.mobileOpen = nextOpen ? 'true' : 'false'; + backdrop.dataset.mobileOpen = nextOpen ? 'true' : 'false'; + backdrop.classList.toggle('hidden', !nextOpen); + toggle.setAttribute('aria-expanded', nextOpen ? 'true' : 'false'); + body.classList.toggle('overflow-hidden', nextOpen); + }; + + const openMenu = (): void => { + if (!isMobileViewport()) return; + if (menu.dataset.mobileOpen === 'true') return; + + lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; + setOpen(true); + + const focusTarget = menu.querySelector( + 'a, button, input, [tabindex]:not([tabindex="-1"])' + ); + focusTarget?.focus(); + }; + + const closeMenu = (): void => { + if (menu.dataset.mobileOpen !== 'true') return; + setOpen(false); + lastFocused?.focus(); + }; + + toggle.addEventListener('click', () => { + if (menu.dataset.mobileOpen === 'true') { + closeMenu(); + return; + } + + openMenu(); + }); + + backdrop.addEventListener('click', closeMenu); + + document.addEventListener('keydown', event => { + if (event.key !== 'Escape') return; + if (menu.dataset.mobileOpen !== 'true') return; + event.preventDefault(); + closeMenu(); + }); + + menu.querySelectorAll('a, button').forEach(el => { + el.addEventListener('click', () => { + if (!isMobileViewport()) return; + closeMenu(); + }); + }); + + window.addEventListener('resize', () => { + if (!isMobileViewport()) { + setOpen(false); + } + }); +}; + +onReady(() => { + initSidebarTransitions(); + initMobileMenu(); +}); diff --git a/static/watchlist.ts b/static/watchlist.ts new file mode 100644 index 0000000..e0951b9 --- /dev/null +++ b/static/watchlist.ts @@ -0,0 +1,326 @@ +export {}; + +type WatchlistStatus = 'watching' | 'completed' | 'plan_to_watch' | 'dropped'; + +type WatchlistUpdateDisplay = + | 'Watching' + | 'Completed' + | 'Plan to Watch' + | 'Dropped' + | 'Add to Watchlist'; + +const watchlistIds = new Set(); +const inflight = new Set(); + +const getShowToast = (): ((opts: { message: string; duration?: number }) => void) | null => { + const anyWindow = window as unknown as { + showToast?: (opts: { message: string; duration?: number }) => void; + }; + return typeof anyWindow.showToast === 'function' ? anyWindow.showToast : null; +}; + +const toast = (message: string): void => { + getShowToast()?.({ message }); +}; + +const toInt = (value: string | undefined): number | null => { + if (!value) return null; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : null; +}; + +const withTimeout = async (promise: Promise, ms: number): Promise => { + let timeoutId: number | undefined; + const timeout = new Promise((_, reject) => { + timeoutId = window.setTimeout(() => reject(new Error('timeout')), ms); + }); + + try { + return await Promise.race([promise, timeout]); + } finally { + if (typeof timeoutId === 'number') { + window.clearTimeout(timeoutId); + } + } +}; + +const requestJson = async (input: string, init: RequestInit): Promise => + withTimeout(fetch(input, init), 12_000); + +const syncRemoveButtonVisibility = (id: number): void => { + const container = document.getElementById(`remove-watchlist-container-${id}`); + if (!container) return; + container.classList.toggle('hidden', !watchlistIds.has(id)); +}; + +const syncWatchlistDropdown = (id: number, inWatchlist: boolean): void => { + const statusDisplay = document.getElementById(`watchlist-status-display-${id}`); + if (!statusDisplay) return; + statusDisplay.textContent = inWatchlist ? 'Plan to Watch' : 'Add to Watchlist'; + syncRemoveButtonVisibility(id); +}; + +const syncIconsForId = (id: number): void => { + const shouldBeInWatchlist = watchlistIds.has(id); + document.querySelectorAll('[data-watchlist-toggle][data-mal-id]').forEach(button => { + const malId = toInt(button.dataset.malId); + if (malId !== id) return; + button.classList.toggle('in-watchlist', shouldBeInWatchlist); + button.setAttribute( + 'aria-label', + shouldBeInWatchlist ? 'Remove from Watchlist' : 'Add to Watchlist' + ); + button.toggleAttribute('aria-busy', inflight.has(id)); + }); +}; + +const setBusy = (id: number, busy: boolean): void => { + if (busy) { + inflight.add(id); + } else { + inflight.delete(id); + } + + document + .querySelectorAll('[data-watchlist-toggle][data-mal-id]') + .forEach(button => { + const malId = toInt(button.dataset.malId); + if (malId !== id) return; + button.disabled = busy; + button.toggleAttribute('aria-busy', busy); + }); + + document + .querySelectorAll( + '[data-watchlist-update][data-mal-id], [data-watchlist-remove][data-mal-id]' + ) + .forEach(button => { + const malId = toInt(button.dataset.malId); + if (malId !== id) return; + button.disabled = busy; + button.toggleAttribute('aria-busy', busy); + }); +}; + +const closeClosestDropdown = (from: HTMLElement): void => { + requestAnimationFrame(() => { + const dropdown = from.closest('ui-dropdown') as { close?: () => void } | null; + dropdown?.close?.(); + }); +}; + +const toggleWatchlist = async (id: number, title: string): Promise => { + if (inflight.has(id)) return; + const isInWatchlist = watchlistIds.has(id); + + setBusy(id, true); + + const optimisticNext = !isInWatchlist; + if (optimisticNext) { + watchlistIds.add(id); + } else { + watchlistIds.delete(id); + } + syncIconsForId(id); + syncWatchlistDropdown(id, optimisticNext); + + const url = isInWatchlist ? `/api/watchlist/${id}` : '/api/watchlist'; + const method: 'DELETE' | 'POST' = isInWatchlist ? 'DELETE' : 'POST'; + const body = isInWatchlist + ? null + : JSON.stringify({ animeId: id, status: 'plan_to_watch' satisfies WatchlistStatus }); + + try { + const response = await requestJson(url, { + method, + headers: body ? { 'Content-Type': 'application/json' } : {}, + body: body ?? undefined, + }); + + if (!response.ok) { + throw new Error('not ok'); + } + + toast(optimisticNext ? `Added ${title} to watchlist` : `Removed ${title} from watchlist`); + } catch { + if (optimisticNext) { + watchlistIds.delete(id); + } else { + watchlistIds.add(id); + } + syncIconsForId(id); + syncWatchlistDropdown(id, watchlistIds.has(id)); + toast('Failed to update watchlist'); + } finally { + setBusy(id, false); + syncIconsForId(id); + syncRemoveButtonVisibility(id); + } +}; + +const updateWatchlist = async ( + id: number, + status: WatchlistStatus, + display: WatchlistUpdateDisplay, + title: string, + source: HTMLElement +): Promise => { + if (inflight.has(id)) return; + setBusy(id, true); + + const wasInWatchlist = watchlistIds.has(id); + watchlistIds.add(id); + syncIconsForId(id); + syncRemoveButtonVisibility(id); + + try { + const response = await requestJson('/api/watchlist', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ animeId: id, status }), + }); + + if (!response.ok) { + throw new Error('not ok'); + } + + const statusDisplay = document.getElementById(`watchlist-status-display-${id}`); + if (statusDisplay) { + statusDisplay.textContent = display; + } + + closeClosestDropdown(source); + toast(`Marked ${title} as ${display}`); + } catch { + if (!wasInWatchlist) { + watchlistIds.delete(id); + } + syncIconsForId(id); + syncRemoveButtonVisibility(id); + toast('Failed to update watchlist'); + } finally { + setBusy(id, false); + } +}; + +const removeWatchlist = async (id: number, title: string, source: HTMLElement): Promise => { + if (inflight.has(id)) return; + setBusy(id, true); + + const wasInWatchlist = watchlistIds.has(id); + watchlistIds.delete(id); + syncIconsForId(id); + syncWatchlistDropdown(id, false); + + try { + const response = await requestJson(`/api/watchlist/${id}`, { method: 'DELETE' }); + if (!response.ok) { + throw new Error('not ok'); + } + + closeClosestDropdown(source); + toast(`Removed ${title} from watchlist`); + + const card = source.closest('.watchlist-item'); + if (card instanceof HTMLElement) { + card.remove(); + const remaining = document.querySelectorAll('.watchlist-item').length; + if (remaining === 0) { + window.setTimeout(() => window.location.reload(), 50); + } + } + } catch { + if (wasInWatchlist) { + watchlistIds.add(id); + } + syncIconsForId(id); + syncWatchlistDropdown(id, watchlistIds.has(id)); + toast('Failed to update watchlist'); + } finally { + setBusy(id, false); + syncRemoveButtonVisibility(id); + } +}; + +const initWatchlist = (ids: number[]): void => { + ids.forEach(id => watchlistIds.add(id)); + ids.forEach(id => { + syncRemoveButtonVisibility(id); + syncIconsForId(id); + }); +}; + +const onReady = (fn: () => void): void => { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', fn, { once: true }); + return; + } + + fn(); +}; + +const installDelegatedHandlers = (): void => { + document.addEventListener('click', event => { + const target = event.target; + if (!(target instanceof Element)) return; + + const toggleButton = target.closest('[data-watchlist-toggle]') as HTMLElement | null; + if (toggleButton) { + event.preventDefault(); + event.stopPropagation(); + + const id = toInt(toggleButton.getAttribute('data-mal-id') ?? undefined); + if (id === null) return; + const title = toggleButton.getAttribute('data-watchlist-title') ?? 'anime'; + void toggleWatchlist(id, title); + return; + } + + const updateButton = target.closest('[data-watchlist-update]') as HTMLElement | null; + if (updateButton) { + event.preventDefault(); + event.stopPropagation(); + const id = toInt(updateButton.getAttribute('data-mal-id') ?? undefined); + if (id === null) return; + const status = updateButton.getAttribute('data-watchlist-status') as WatchlistStatus | null; + const display = updateButton.getAttribute( + 'data-watchlist-display' + ) as WatchlistUpdateDisplay | null; + const title = updateButton.getAttribute('data-watchlist-title') ?? 'anime'; + if (!status || !display) return; + void updateWatchlist(id, status, display, title, updateButton); + return; + } + + const removeButton = target.closest('[data-watchlist-remove]') as HTMLElement | null; + if (removeButton) { + event.preventDefault(); + event.stopPropagation(); + const id = toInt(removeButton.getAttribute('data-mal-id') ?? undefined); + if (id === null) return; + const title = removeButton.getAttribute('data-watchlist-title') ?? 'anime'; + void removeWatchlist(id, title, removeButton); + } + }); +}; + +declare global { + interface Window { + initWatchlist: (ids: number[]) => void; + __WATCHLIST_IDS__?: unknown; + } +} + +window.initWatchlist = initWatchlist; + +onReady(() => { + const raw = window.__WATCHLIST_IDS__; + if (Array.isArray(raw)) { + const ids: number[] = raw.filter((entry): entry is number => typeof entry === 'number'); + if (ids.length > 0) { + initWatchlist(ids); + } + } + + installDelegatedHandlers(); +}); diff --git a/templates/anime.gohtml b/templates/anime.gohtml index 7fa3a34..c62ff1f 100644 --- a/templates/anime.gohtml +++ b/templates/anime.gohtml @@ -92,7 +92,7 @@ {{define "title"}}{{.Anime.DisplayTitle}}{{end}} {{define "content"}} -{{if .WatchlistIDs}}{{end}} +{{if .WatchlistIDs}}{{end}} {{$anime := .Anime}}
diff --git a/templates/base.gohtml b/templates/base.gohtml index d1d6e49..15b914f 100644 --- a/templates/base.gohtml +++ b/templates/base.gohtml @@ -53,6 +53,11 @@ html[data-theme="dark"] .theme-icon-light { display: block; } html[data-theme="light"] .theme-icon-light { display: none; } html[data-theme="light"] .theme-icon-dark { display: block; } + + [data-htmx-loading="true"] { + opacity: 0.65; + pointer-events: none; + }