From 7a48f66a7381f7a26d09efbafc64e17405dc6519 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 15 Apr 2026 00:23:35 +0200 Subject: [PATCH] refactor: add root static source files --- static/anime.ts | 71 +++++++++++++ static/auth.ts | 27 +++++ static/discover.ts | 52 ++++++++++ static/search.ts | 167 ++++++++++++++++++++++++++++++ static/style.css | 35 +++++++ static/timezone.ts | 250 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 602 insertions(+) create mode 100644 static/anime.ts create mode 100644 static/auth.ts create mode 100644 static/discover.ts create mode 100644 static/search.ts create mode 100644 static/style.css create mode 100644 static/timezone.ts diff --git a/static/anime.ts b/static/anime.ts new file mode 100644 index 0000000..996fe12 --- /dev/null +++ b/static/anime.ts @@ -0,0 +1,71 @@ +export {} + +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 setDropdownMenuState = (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 setWatchlistDropdownState = (isOpen: boolean): void => { + const dropdown = document.getElementById('watchlist-dropdown') + if (!dropdown) { + return + } + + dropdown.classList.toggle('open', isOpen) + const menu = dropdown.querySelector('[data-dropdown-menu]') + if (menu instanceof HTMLElement) { + setDropdownMenuState(menu, isOpen) + } +} + +const toggleWatchlistDropdown = (): void => { + const dropdown = document.getElementById('watchlist-dropdown') + if (!dropdown) { + return + } + + setWatchlistDropdownState(!dropdown.classList.contains('open')) +} + +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/auth.ts b/static/auth.ts new file mode 100644 index 0000000..e642b71 --- /dev/null +++ b/static/auth.ts @@ -0,0 +1,27 @@ +export {} + +function copyRecoveryKey(keyElementId: string, feedbackElementId: string): void { + const keyElement = document.getElementById(keyElementId) + const feedbackElement = document.getElementById(feedbackElementId) + + if (!keyElement || !feedbackElement) { + return + } + + const key = keyElement.textContent || '' + navigator.clipboard + .writeText(key) + .then((): void => { + feedbackElement.textContent = 'Recovery key copied.' + }) + .catch((): void => { + feedbackElement.textContent = 'Copy failed. Select and copy manually.' + }) +} + +function confirmDangerAction(message: string): boolean { + return window.confirm(message) +} + +;(window as Window & { copyRecoveryKey?: typeof copyRecoveryKey; confirmDangerAction?: typeof confirmDangerAction }).copyRecoveryKey = copyRecoveryKey +;(window as Window & { copyRecoveryKey?: typeof copyRecoveryKey; confirmDangerAction?: typeof confirmDangerAction }).confirmDangerAction = confirmDangerAction diff --git a/static/discover.ts b/static/discover.ts new file mode 100644 index 0000000..e12c804 --- /dev/null +++ b/static/discover.ts @@ -0,0 +1,52 @@ +export {} + +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 setActiveDiscoverTab = (clickedTab: Element): void => { + const group = clickedTab.closest('[data-tab-group="discover"]') + if (!group) { + return + } + + 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/search.ts b/static/search.ts new file mode 100644 index 0000000..c76fb77 --- /dev/null +++ b/static/search.ts @@ -0,0 +1,167 @@ +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 + + if (!searchInput || !searchDropdown) { + return + } + + searchInput.addEventListener('input', onSearchInput) + searchInput.addEventListener('blur', onSearchBlur) + document.addEventListener('click', onDocumentClick) +} + +initQuickSearch() diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..ec08138 --- /dev/null +++ b/static/style.css @@ -0,0 +1,35 @@ +@import 'tailwindcss'; + +@source '../../internal/**/*.templ'; + +:root { + --bg: #111419; + --panel: #181d24; + --panel-soft: #1f2530; + --header: #1a2029; + --text: #e7eaf0; + --text-muted: #b8c0cd; + --text-faint: #8b97a8; + --accent: #cad4e4; + --danger: #d17f88; + --surface-search: rgba(10, 13, 18, 0.3); + --surface-search-focus-border: rgba(202, 212, 228, 0.24); + --surface-thumb: #141920; + --surface-input: #151b23; + --surface-input-focus: #1c2531; + --surface-tab-hover: #2a3340; + --surface-tab-active: #323d4c; + --surface-select: #1a212b; + --surface-search-view-all: #151b23; + --text-on-accent: #111419; + --overlay-subtle: rgba(0, 0, 0, 0.45); + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --poster-max-height: 360px; + --font: 'Verdana', 'Tahoma', 'Segoe UI', sans-serif; +} diff --git a/static/timezone.ts b/static/timezone.ts new file mode 100644 index 0000000..1b8158c --- /dev/null +++ b/static/timezone.ts @@ -0,0 +1,250 @@ +export {} + +const jstOffsetMinutes = 9 * 60 + + type ParsedBroadcast = { + day: string + hour: number + minute: number + } + + const parseBroadcastTime = (value: string | null): { hour: number; minute: number } | null => { + if (!value || typeof value !== 'string') { + return null + } + + const match = value.trim().match(/^(\d{1,2}):(\d{2})$/) + if (!match) { + return null + } + + const hour = Number.parseInt(match[1], 10) + const minute = Number.parseInt(match[2], 10) + if (Number.isNaN(hour) || Number.isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) { + return null + } + + return { hour, minute } + } + + const isJstTimezone = (timezone: string | null): boolean => { + if (!timezone) { + return true + } + + const normalized = timezone.trim().toLowerCase() + return normalized === 'asia/tokyo' || normalized === 'jst' + } + + const parseFromStructuredAttrs = (node: Element): ParsedBroadcast | null => { + const day = node.getAttribute('data-broadcast-day') + const time = node.getAttribute('data-broadcast-time') + const timezone = node.getAttribute('data-broadcast-timezone') + + if (!day || !time || !isJstTimezone(timezone)) { + return null + } + + const parsedTime = parseBroadcastTime(time) + if (!parsedTime) { + return null + } + + return { day: day.trim(), hour: parsedTime.hour, minute: parsedTime.minute } + } + + const parseBroadcast = (text: string | null): ParsedBroadcast | null => { + if (!text || typeof text !== 'string') { + return null + } + + const match = text.match(/^(.*) at (\d{1,2}):(\d{2}) \(JST\)$/i) + if (!match) { + return null + } + + const day = match[1].trim() + const hour = Number.parseInt(match[2], 10) + const minute = Number.parseInt(match[3], 10) + + if (Number.isNaN(hour) || Number.isNaN(minute)) { + return null + } + + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { + return null + } + + return { day, hour, minute } + } + + const normalizeDay = (day: string): number | null => { + const key = day.trim().toLowerCase().replace(/s$/, '') + const days: Record = { + mon: 1, + monday: 1, + tue: 2, + tues: 2, + tuesday: 2, + wed: 3, + wednesday: 3, + thu: 4, + thur: 4, + thurs: 4, + thursday: 4, + fri: 5, + friday: 5, + sat: 6, + saturday: 6, + sun: 0, + sunday: 0, + } + + if (typeof days[key] !== 'number') { + return null + } + + return days[key] + } + + const convertToLocal = (parsed: ParsedBroadcast, localOffsetMinutes: number): string | null => { + const sourceMinutes = parsed.hour * 60 + parsed.minute + const diff = jstOffsetMinutes - localOffsetMinutes + const localTotal = sourceMinutes - diff + + const dayShift = Math.floor(localTotal / 1440) + const normalizedMinutes = ((localTotal % 1440) + 1440) % 1440 + const localHour = Math.floor(normalizedMinutes / 60) + const localMinute = normalizedMinutes % 60 + + const sourceDayIndex = normalizeDay(parsed.day) + if (sourceDayIndex === null) { + return null + } + + const localDayIndex = ((sourceDayIndex + dayShift) % 7 + 7) % 7 + const localDay = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][localDayIndex] + + const time = `${localHour.toString().padStart(2, '0')}:${localMinute.toString().padStart(2, '0')}` + return `${localDay} at ${time} (Local)` + } + + const nextAiringUTC = (parsed: ParsedBroadcast): Date | null => { + const targetDay = normalizeDay(parsed.day) + if (targetDay === null) { + return null + } + + const now = new Date() + const jstNow = new Date(now.getTime() + jstOffsetMinutes * 60 * 1000) + + const currentDay = jstNow.getUTCDay() + const currentMinuteOfDay = jstNow.getUTCHours() * 60 + jstNow.getUTCMinutes() + const targetMinuteOfDay = parsed.hour * 60 + parsed.minute + + let dayDelta = (targetDay - currentDay + 7) % 7 + if (dayDelta === 0 && targetMinuteOfDay <= currentMinuteOfDay) { + dayDelta = 7 + } + + const minuteDelta = dayDelta * 1440 + (targetMinuteOfDay - currentMinuteOfDay) + return new Date(now.getTime() + minuteDelta * 60 * 1000) + } + + const formatRelative = (value: number, unit: Intl.RelativeTimeFormatUnit): string => { + if (typeof Intl !== 'undefined' && typeof Intl.RelativeTimeFormat === 'function') { + const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) + return formatter.format(value, unit) + } + + const suffix = value === 1 ? unit : `${unit}s` + return `in ${value} ${suffix}` + } + + const relativeText = (target: Date): string => { + const diffMs = target.getTime() - Date.now() + if (diffMs <= 0) { + return 'soon' + } + + const minutes = Math.ceil(diffMs / 60000) + if (minutes < 60) { + return formatRelative(minutes, 'minute') + } + + const hours = Math.ceil(minutes / 60) + if (hours < 36) { + return formatRelative(hours, 'hour') + } + + const days = Math.ceil(hours / 24) + return formatRelative(days, 'day') + } + + const localDateTimeText = (date: Date): string => { + const formatter = new Intl.DateTimeFormat(undefined, { + weekday: 'short', + hour: '2-digit', + minute: '2-digit', + }) + return formatter.format(date) + } + + const updateNextAiring = (node: Element, parsed: ParsedBroadcast): void => { + const card = node.closest('[data-notification-content]') + if (!card) { + return + } + + const nextNode = card.querySelector('[data-next-airing]') + if (!(nextNode instanceof HTMLElement)) { + return + } + + const nextDate = nextAiringUTC(parsed) + if (!nextDate) { + nextNode.remove() + return + } + + nextNode.textContent = `Next episode ${relativeText(nextDate)} (${localDateTimeText(nextDate)})` + } + + const updateNode = (node: Element, localOffsetMinutes: number): void => { + const card = node.closest('[data-notification-content]') + const nextNode = card ? card.querySelector('[data-next-airing]') : null + + const structured = parseFromStructuredAttrs(node) + const source = node.getAttribute('data-jst-text') + const parsed = structured || parseBroadcast(source) + if (!parsed) { + if (nextNode instanceof HTMLElement) { + nextNode.remove() + } + return + } + + const converted = convertToLocal(parsed, localOffsetMinutes) + if (!converted) { + if (nextNode instanceof HTMLElement) { + nextNode.remove() + } + return + } + + node.textContent = converted + updateNextAiring(node, parsed) + } + + const updateAll = (): void => { + const localOffsetMinutes = -new Date().getTimezoneOffset() + const nodes = document.querySelectorAll('[data-jst-text]') + nodes.forEach((node: Element): void => updateNode(node, localOffsetMinutes)) + } + +const initTimezoneConversion = (): void => { + document.addEventListener('DOMContentLoaded', updateAll) + document.body.addEventListener('htmx:afterSwap', updateAll) +} + +initTimezoneConversion()