From b5bc9c23cc1e3536db3e6efdc9713137dde3bfd6 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Tue, 14 Apr 2026 23:43:50 +0200 Subject: [PATCH] refactor: migrate browser scripts to ts --- static/js/anime.js | 52 +++-- static/js/auth.js | 34 ++-- static/js/discover.js | 49 +++-- static/js/search.js | 211 ++++++++++---------- static/js/timezone.js | 435 +++++++++++++++++++----------------------- static/ts/anime.ts | 28 +++ static/ts/auth.ts | 25 +++ static/ts/discover.ts | 26 +++ static/ts/search.ts | 127 ++++++++++++ static/ts/timezone.ts | 246 ++++++++++++++++++++++++ 10 files changed, 819 insertions(+), 414 deletions(-) create mode 100644 static/ts/anime.ts create mode 100644 static/ts/auth.ts create mode 100644 static/ts/discover.ts create mode 100644 static/ts/search.ts create mode 100644 static/ts/timezone.ts diff --git a/static/js/anime.js b/static/js/anime.js index c10c5a1..d719dbf 100644 --- a/static/js/anime.js +++ b/static/js/anime.js @@ -1,28 +1,24 @@ -;(function () { - const toggleDropdown = () => { - const dropdown = document.getElementById('watchlist-dropdown') - if (!dropdown) { - return - } - - dropdown.classList.toggle('open') - } - - window.toggleDropdown = toggleDropdown - - document.addEventListener('click', (event) => { - const dropdown = document.getElementById('watchlist-dropdown') - if (!dropdown) { - return - } - - const target = event.target - if (!(target instanceof Node)) { - return - } - - if (!dropdown.contains(target)) { - dropdown.classList.remove('open') - } - }) -})() +"use strict"; +(() => { + const toggleDropdown = () => { + const dropdown = document.getElementById('watchlist-dropdown'); + if (!dropdown) { + return; + } + dropdown.classList.toggle('open'); + }; + window.toggleDropdown = toggleDropdown; + document.addEventListener('click', (event) => { + const dropdown = document.getElementById('watchlist-dropdown'); + if (!dropdown) { + return; + } + const target = event.target; + if (!(target instanceof Node)) { + return; + } + if (!dropdown.contains(target)) { + dropdown.classList.remove('open'); + } + }); +})(); diff --git a/static/js/auth.js b/static/js/auth.js index a1320a3..a1ce6fc 100644 --- a/static/js/auth.js +++ b/static/js/auth.js @@ -1,19 +1,23 @@ +"use strict"; function copyRecoveryKey(keyElementId, feedbackElementId) { - var keyElement = document.getElementById(keyElementId) - var feedbackElement = document.getElementById(feedbackElementId) - - if (!keyElement || !feedbackElement) { - return - } - - var key = keyElement.textContent || '' - navigator.clipboard.writeText(key).then(function () { - feedbackElement.textContent = 'Recovery key copied.' - }).catch(function () { - feedbackElement.textContent = 'Copy failed. Select and copy manually.' - }) + const keyElement = document.getElementById(keyElementId); + const feedbackElement = document.getElementById(feedbackElementId); + if (!keyElement || !feedbackElement) { + return; + } + const key = keyElement.textContent || ''; + navigator.clipboard + .writeText(key) + .then(() => { + feedbackElement.textContent = 'Recovery key copied.'; + }) + .catch(() => { + feedbackElement.textContent = 'Copy failed. Select and copy manually.'; + }); } - function confirmDangerAction(message) { - return window.confirm(message) + return window.confirm(message); } +; +window.copyRecoveryKey = copyRecoveryKey; +window.confirmDangerAction = confirmDangerAction; diff --git a/static/js/discover.js b/static/js/discover.js index f445a94..8192ab5 100644 --- a/static/js/discover.js +++ b/static/js/discover.js @@ -1,26 +1,23 @@ -;(function () { - const setActiveTab = (clickedTab) => { - const group = clickedTab.closest('[data-tab-group="discover"]') - if (!group) { - return - } - - const triggers = group.querySelectorAll('[data-tab-trigger]') - triggers.forEach((tab) => tab.classList.remove('active')) - clickedTab.classList.add('active') - } - - document.addEventListener('click', (event) => { - const target = event.target - if (!(target instanceof Element)) { - return - } - - const trigger = target.closest('[data-tab-trigger]') - if (!trigger) { - return - } - - setActiveTab(trigger) - }) -})() +"use strict"; +(() => { + const setActiveTab = (clickedTab) => { + const group = clickedTab.closest('[data-tab-group="discover"]'); + if (!group) { + return; + } + const triggers = group.querySelectorAll('[data-tab-trigger]'); + triggers.forEach((tab) => tab.classList.remove('active')); + clickedTab.classList.add('active'); + }; + document.addEventListener('click', (event) => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + const trigger = target.closest('[data-tab-trigger]'); + if (!trigger) { + return; + } + setActiveTab(trigger); + }); +})(); diff --git a/static/js/search.js b/static/js/search.js index fce14d3..c3704c0 100644 --- a/static/js/search.js +++ b/static/js/search.js @@ -1,108 +1,109 @@ -(function() { - if (window.searchInitialized) return - window.searchInitialized = true - - let searchTimeout - const searchInput = document.getElementById('search-input') - const searchDropdown = document.getElementById('search-dropdown') - - if (searchInput) { - searchInput.addEventListener('input', function(e) { - clearTimeout(searchTimeout) - const query = e.target.value.trim() - - if (query.length < 2) { - searchDropdown.replaceChildren() - return - } - - searchTimeout = setTimeout(() => { - fetch('/api/search-quick?q=' + encodeURIComponent(query)) - .then(res => res.json()) - .then(results => { - if (!results || results.length === 0) { - searchDropdown.replaceChildren() - return - } - - const searchResults = document.createElement('div') - searchResults.className = 'search-results' - - const title = document.createElement('div') - title.className = 'search-results-title' - title.textContent = 'Anime' - searchResults.appendChild(title) - - results.forEach(r => { - const item = document.createElement('a') - item.className = 'search-result-item' - item.setAttribute('href', '/anime/' + encodeURIComponent(String(r.id || ''))) - - if (isSafeImageUrl(r.image)) { - const img = document.createElement('img') - img.className = 'search-result-thumb' - img.setAttribute('src', r.image) - img.setAttribute('alt', String(r.title || '')) - item.appendChild(img) - } else { - const noImage = document.createElement('div') - noImage.className = 'search-result-no-image' - noImage.textContent = 'no image' - item.appendChild(noImage) - } - - const info = document.createElement('div') - info.className = 'search-result-info' - - const itemTitle = document.createElement('div') - itemTitle.className = 'search-result-title' - itemTitle.textContent = String(r.title || '') - info.appendChild(itemTitle) - - const itemType = document.createElement('div') - itemType.className = 'search-result-type' - itemType.textContent = String(r.type || '') - info.appendChild(itemType) - - item.appendChild(info) - searchResults.appendChild(item) +"use strict"; +(() => { + const globalWindow = window; + if (globalWindow.searchInitialized) { + return; + } + globalWindow.searchInitialized = true; + let searchTimeout; + const searchInput = document.getElementById('search-input'); + const searchDropdown = document.getElementById('search-dropdown'); + if (!searchInput || !searchDropdown) { + return; + } + searchInput.addEventListener('input', (event) => { + if (searchTimeout) { + window.clearTimeout(searchTimeout); + } + const target = event.target; + if (!(target instanceof HTMLInputElement)) { + return; + } + const query = target.value.trim(); + if (query.length < 2) { + searchDropdown.replaceChildren(); + return; + } + searchTimeout = window.setTimeout(() => { + fetch('/api/search-quick?q=' + encodeURIComponent(query)) + .then((res) => res.json()) + .then((results) => { + if (!results || results.length === 0) { + searchDropdown.replaceChildren(); + return; + } + const searchResults = document.createElement('div'); + searchResults.className = 'search-results'; + const title = document.createElement('div'); + title.className = 'search-results-title'; + title.textContent = 'Anime'; + searchResults.appendChild(title); + results.forEach((result) => { + const item = document.createElement('a'); + item.className = 'search-result-item'; + item.setAttribute('href', '/anime/' + encodeURIComponent(String(result.id || ''))); + if (isSafeImageUrl(result.image)) { + const img = document.createElement('img'); + img.className = 'search-result-thumb'; + img.setAttribute('src', result.image || ''); + img.setAttribute('alt', String(result.title || '')); + item.appendChild(img); + } + else { + const noImage = document.createElement('div'); + noImage.className = 'search-result-no-image'; + noImage.textContent = 'no image'; + item.appendChild(noImage); + } + const info = document.createElement('div'); + info.className = 'search-result-info'; + const itemTitle = document.createElement('div'); + itemTitle.className = 'search-result-title'; + itemTitle.textContent = String(result.title || ''); + info.appendChild(itemTitle); + const itemType = document.createElement('div'); + itemType.className = 'search-result-type'; + itemType.textContent = String(result.type || ''); + info.appendChild(itemType); + item.appendChild(info); + searchResults.appendChild(item); + }); + const viewAll = document.createElement('a'); + viewAll.className = 'search-result-view-all'; + viewAll.setAttribute('href', '/search?q=' + encodeURIComponent(query)); + viewAll.textContent = 'View all results for ' + query; + searchResults.appendChild(viewAll); + searchDropdown.replaceChildren(searchResults); }) - - const viewAll = document.createElement('a') - viewAll.className = 'search-result-view-all' - viewAll.setAttribute('href', '/search?q=' + encodeURIComponent(query)) - viewAll.textContent = 'View all results for ' + query - searchResults.appendChild(viewAll) - - searchDropdown.replaceChildren(searchResults) - }) - .catch(err => console.error('Search error:', err)) - }, 300) - }) - + .catch((err) => { + console.error('Search error:', err); + }); + }, 300); + }); searchInput.addEventListener('blur', () => { - setTimeout(() => { - searchDropdown.replaceChildren() - }, 200) - }) - - document.addEventListener('click', (e) => { - if (!e.target.closest('.header-search-wrapper')) { - searchDropdown.replaceChildren() - } - }) - } - - function isSafeImageUrl(rawUrl) { - if (!rawUrl || typeof rawUrl !== 'string') { - return false + window.setTimeout(() => { + searchDropdown.replaceChildren(); + }, 200); + }); + document.addEventListener('click', (event) => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + if (!target.closest('.header-search-wrapper')) { + searchDropdown.replaceChildren(); + } + }); + function isSafeImageUrl(rawUrl) { + 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; + } } - - try { - const parsed = new URL(rawUrl, window.location.origin) - return parsed.protocol === 'https:' || parsed.protocol === 'http:' - } catch { - return false - } - } -})() +})(); diff --git a/static/js/timezone.js b/static/js/timezone.js index 66ce69b..50ea426 100644 --- a/static/js/timezone.js +++ b/static/js/timezone.js @@ -1,240 +1,195 @@ -;(function () { - const jstOffsetMinutes = 9 * 60 - - const parseBroadcastTime = (value) => { - 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) => { - if (!timezone) { - return true - } - - const normalized = timezone.trim().toLowerCase() - return normalized === 'asia/tokyo' || normalized === 'jst' - } - - const parseFromStructuredAttrs = (node) => { - 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) => { - 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) => { - const key = day.trim().toLowerCase().replace(/s$/, '') - const days = { - 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, localOffsetMinutes) => { - 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) => { - 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 relativeText = (target) => { - 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 formatRelative = (value, unit) => { - 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 localDateTimeText = (date) => { - const formatter = new Intl.DateTimeFormat(undefined, { - weekday: 'short', - hour: '2-digit', - minute: '2-digit', - }) - return formatter.format(date) - } - - const updateNextAiring = (node, parsed) => { - const card = node.closest('.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, localOffsetMinutes) => { - const card = node.closest('.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 = () => { - const localOffsetMinutes = -new Date().getTimezoneOffset() - const nodes = document.querySelectorAll('[data-jst-text]') - nodes.forEach((node) => updateNode(node, localOffsetMinutes)) - } - - document.addEventListener('DOMContentLoaded', updateAll) - document.body.addEventListener('htmx:afterSwap', updateAll) -})() +"use strict"; +(() => { + const jstOffsetMinutes = 9 * 60; + const parseBroadcastTime = (value) => { + 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) => { + if (!timezone) { + return true; + } + const normalized = timezone.trim().toLowerCase(); + return normalized === 'asia/tokyo' || normalized === 'jst'; + }; + const parseFromStructuredAttrs = (node) => { + 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) => { + 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) => { + const key = day.trim().toLowerCase().replace(/s$/, ''); + const days = { + 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, localOffsetMinutes) => { + 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) => { + 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, unit) => { + 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) => { + 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) => { + const formatter = new Intl.DateTimeFormat(undefined, { + weekday: 'short', + hour: '2-digit', + minute: '2-digit', + }); + return formatter.format(date); + }; + const updateNextAiring = (node, parsed) => { + const card = node.closest('.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, localOffsetMinutes) => { + const card = node.closest('.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 = () => { + const localOffsetMinutes = -new Date().getTimezoneOffset(); + const nodes = document.querySelectorAll('[data-jst-text]'); + nodes.forEach((node) => updateNode(node, localOffsetMinutes)); + }; + document.addEventListener('DOMContentLoaded', updateAll); + document.body.addEventListener('htmx:afterSwap', updateAll); +})(); diff --git a/static/ts/anime.ts b/static/ts/anime.ts new file mode 100644 index 0000000..d823c78 --- /dev/null +++ b/static/ts/anime.ts @@ -0,0 +1,28 @@ +((): void => { + const toggleDropdown = (): void => { + const dropdown = document.getElementById('watchlist-dropdown') + if (!dropdown) { + return + } + + dropdown.classList.toggle('open') + } + + ;(window as Window & { toggleDropdown?: () => void }).toggleDropdown = toggleDropdown + + document.addEventListener('click', (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)) { + dropdown.classList.remove('open') + } + }) +})() diff --git a/static/ts/auth.ts b/static/ts/auth.ts new file mode 100644 index 0000000..dab5040 --- /dev/null +++ b/static/ts/auth.ts @@ -0,0 +1,25 @@ +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/ts/discover.ts b/static/ts/discover.ts new file mode 100644 index 0000000..1ab193f --- /dev/null +++ b/static/ts/discover.ts @@ -0,0 +1,26 @@ +((): void => { + const setActiveTab = (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 => tab.classList.remove('active')) + clickedTab.classList.add('active') + } + + 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) + }) +})() diff --git a/static/ts/search.ts b/static/ts/search.ts new file mode 100644 index 0000000..2ee2a55 --- /dev/null +++ b/static/ts/search.ts @@ -0,0 +1,127 @@ +((): void => { + const globalWindow = window as Window & { searchInitialized?: boolean } + if (globalWindow.searchInitialized) { + return + } + globalWindow.searchInitialized = true + + let searchTimeout: number | undefined + const searchInput = document.getElementById('search-input') as HTMLInputElement | null + const searchDropdown = document.getElementById('search-dropdown') + + if (!searchInput || !searchDropdown) { + return + } + + searchInput.addEventListener('input', (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) { + 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 = 'search-results' + + const title = document.createElement('div') + title.className = 'search-results-title' + title.textContent = 'Anime' + searchResults.appendChild(title) + + results.forEach((result): void => { + const item = document.createElement('a') + item.className = 'search-result-item' + item.setAttribute('href', '/anime/' + encodeURIComponent(String(result.id || ''))) + + if (isSafeImageUrl(result.image)) { + const img = document.createElement('img') + img.className = 'search-result-thumb' + img.setAttribute('src', result.image || '') + img.setAttribute('alt', String(result.title || '')) + item.appendChild(img) + } else { + const noImage = document.createElement('div') + noImage.className = 'search-result-no-image' + noImage.textContent = 'no image' + item.appendChild(noImage) + } + + const info = document.createElement('div') + info.className = 'search-result-info' + + const itemTitle = document.createElement('div') + itemTitle.className = 'search-result-title' + itemTitle.textContent = String(result.title || '') + info.appendChild(itemTitle) + + const itemType = document.createElement('div') + itemType.className = 'search-result-type' + itemType.textContent = String(result.type || '') + info.appendChild(itemType) + + item.appendChild(info) + searchResults.appendChild(item) + }) + + const viewAll = document.createElement('a') + viewAll.className = 'search-result-view-all' + 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('.header-search-wrapper')) { + 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 + } + } +})() diff --git a/static/ts/timezone.ts b/static/ts/timezone.ts new file mode 100644 index 0000000..6af5bd1 --- /dev/null +++ b/static/ts/timezone.ts @@ -0,0 +1,246 @@ +((): void => { + 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('.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('.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)) + } + + document.addEventListener('DOMContentLoaded', updateAll) + document.body.addEventListener('htmx:afterSwap', updateAll) +})()