From b9eec9e71aad2278f03798f9a9e8c8d5b05ba560 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 15 Apr 2026 00:23:24 +0200 Subject: [PATCH] refactor: flatten static asset layout --- .gitignore | 4 +- CONTRIBUTING.md | 4 +- README.md | 2 +- internal/templates/layout.templ | 12 +- package.json | 6 +- static/css/style.css | 35 ----- static/js/anime.ts | 71 --------- static/js/auth.ts | 27 ---- static/js/discover.ts | 52 ------- static/js/search.ts | 167 --------------------- static/js/timezone.ts | 250 -------------------------------- tsconfig.json | 2 +- 12 files changed, 15 insertions(+), 617 deletions(-) delete mode 100644 static/css/style.css delete mode 100644 static/js/anime.ts delete mode 100644 static/js/auth.ts delete mode 100644 static/js/discover.ts delete mode 100644 static/js/search.ts delete mode 100644 static/js/timezone.ts diff --git a/.gitignore b/.gitignore index 7497b6a..15f2f39 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,8 @@ node_modules out dist *.tgz -static/css/tailwind.css -static/js/*.js +static/tailwind.css +static/*.js # code coverage coverage diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 402729d..2ee9c2f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,8 +30,8 @@ go test ./... go run ./cmd/server ``` -TypeScript source files live in `static/js/*.ts` and are bundled to matching `static/js/*.js` files for runtime. -Generated `static/js/*.js` and `static/css/tailwind.css` files are ignored by git. +TypeScript source files live in `static/*.ts` and are bundled to matching `static/*.js` files for runtime. +Generated `static/*.js` and `static/tailwind.css` files are ignored by git. ## Development guidelines diff --git a/README.md b/README.md index e8276c5..89d5b4d 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ bun run build:assets go run ./cmd/server ``` -The frontend pipeline uses a single source stylesheet (`static/css/style.css`) and TypeScript sources in `static/js/*.ts`, then emits build artifacts (`static/css/tailwind.css` and `static/js/*.js`) for serving. +The frontend pipeline uses a single source stylesheet (`static/style.css`) and TypeScript sources in `static/*.ts`, then emits build artifacts (`static/tailwind.css` and `static/*.js`) for serving. When the server starts, the app is available at `http://localhost:3000`. diff --git a/internal/templates/layout.templ b/internal/templates/layout.templ index a45d890..abb21cc 100644 --- a/internal/templates/layout.templ +++ b/internal/templates/layout.templ @@ -10,12 +10,12 @@ templ Layout(title string, showHeader bool) { { title } - + - - - - + + + + if showHeader { @@ -48,7 +48,7 @@ templ Layout(title string, showHeader bool) { }> { children... } - + } diff --git a/package.json b/package.json index deb43c9..1ee4103 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,9 @@ "name": "myanimelist-ui", "private": true, "scripts": { - "build:css": "bunx @tailwindcss/cli -i ./static/css/style.css -o ./static/css/tailwind.css", - "watch:css": "bunx @tailwindcss/cli -i ./static/css/style.css -o ./static/css/tailwind.css --watch", - "build:ts": "bun build ./static/js/*.ts --outdir ./static/js --target browser", + "build:css": "bunx @tailwindcss/cli -i ./static/style.css -o ./static/tailwind.css", + "watch:css": "bunx @tailwindcss/cli -i ./static/style.css -o ./static/tailwind.css --watch", + "build:ts": "bun build ./static/*.ts --outdir ./static --target browser", "typecheck": "bunx tsc -p tsconfig.json --noEmit", "build:assets": "bun run build:css && bun run build:ts" }, diff --git a/static/css/style.css b/static/css/style.css deleted file mode 100644 index ec08138..0000000 --- a/static/css/style.css +++ /dev/null @@ -1,35 +0,0 @@ -@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/js/anime.ts b/static/js/anime.ts deleted file mode 100644 index 996fe12..0000000 --- a/static/js/anime.ts +++ /dev/null @@ -1,71 +0,0 @@ -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/js/auth.ts b/static/js/auth.ts deleted file mode 100644 index e642b71..0000000 --- a/static/js/auth.ts +++ /dev/null @@ -1,27 +0,0 @@ -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/js/discover.ts b/static/js/discover.ts deleted file mode 100644 index e12c804..0000000 --- a/static/js/discover.ts +++ /dev/null @@ -1,52 +0,0 @@ -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/js/search.ts b/static/js/search.ts deleted file mode 100644 index c76fb77..0000000 --- a/static/js/search.ts +++ /dev/null @@ -1,167 +0,0 @@ -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/js/timezone.ts b/static/js/timezone.ts deleted file mode 100644 index 1b8158c..0000000 --- a/static/js/timezone.ts +++ /dev/null @@ -1,250 +0,0 @@ -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() diff --git a/tsconfig.json b/tsconfig.json index 2ac835b..df5ef7b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,6 @@ "skipLibCheck": true }, "include": [ - "static/js/**/*.ts" + "static/*.ts" ] }