fix: writing bit stuff cleaner

This commit is contained in:
2026-05-07 11:20:40 +02:00
parent 41f283220e
commit ca5b19209c
11 changed files with 274 additions and 207 deletions

View File

@@ -1,11 +1,15 @@
const dedupe = (): void => {
const seen = new Set<string>()
const elements = document.querySelectorAll('[data-id]')
elements.forEach((item) => {
const id = item.getAttribute('data-id')
if (id && seen.has(id)) {
if (!id) {
return
}
if (seen.has(id)) {
item.remove()
} else if (id) {
} else {
seen.add(id)
}
})
@@ -13,8 +17,9 @@ const dedupe = (): void => {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', dedupe)
} else {
dedupe()
}
// Also run on window load to be sure
} else {
dedupe()
}
window.addEventListener('load', dedupe)
window.addEventListener('load', dedupe)

View File

@@ -7,7 +7,7 @@ const setActiveDiscoverTab = (clickedTab: Element): void => {
}
const triggers = group.querySelectorAll('[data-tab-trigger]')
triggers.forEach((tab: Element): void => {
triggers.forEach((tab) => {
const activeClasses = parseClassList(tab.getAttribute('data-tab-active-classes'))
const inactiveClasses = parseClassList(tab.getAttribute('data-tab-inactive-classes'))
tab.classList.remove(...activeClasses)

View File

@@ -9,7 +9,7 @@ class UIDropdown extends HTMLElement {
this.handleClickOutside = this.handleClickOutside.bind(this)
}
connectedCallback() {
connectedCallback(): void {
const trigger = this.querySelector('[data-trigger]')
this.contentEl = this.querySelector('[data-content]')
@@ -20,7 +20,7 @@ class UIDropdown extends HTMLElement {
document.addEventListener('click', this.handleClickOutside)
}
disconnectedCallback() {
disconnectedCallback(): void {
const trigger = this.querySelector('[data-trigger]')
if (trigger) {
trigger.removeEventListener('click', this.toggle)
@@ -28,8 +28,10 @@ class UIDropdown extends HTMLElement {
document.removeEventListener('click', this.handleClickOutside)
}
toggle() {
if (this.isClosing) return
toggle(): void {
if (this.isClosing) {
return
}
this.isOpen = !this.isOpen
if (this.contentEl) {
if (this.isOpen) {
@@ -40,8 +42,10 @@ class UIDropdown extends HTMLElement {
}
}
close() {
if (this.isClosing) return
close(): void {
if (this.isClosing) {
return
}
this.isClosing = true
this.isOpen = false
if (this.contentEl) {
@@ -52,7 +56,7 @@ class UIDropdown extends HTMLElement {
}, 100)
}
handleClickOutside(event: MouseEvent) {
handleClickOutside(event: MouseEvent): void {
if (!this.contains(event.target as Node)) {
this.close()
}

View File

@@ -2,7 +2,7 @@ declare const htmx: {
ajax(verb: string, path: string, target: HTMLElement): Promise<void>
}
export {}
export { }
import DOMPurify from 'dompurify'
@@ -23,32 +23,6 @@ interface SkipSegment {
end: number
}
interface EpisodeData {
mal_id: number
title: string
current_episode: string
total_episodes: number
initial_mode: string
token: string
available_modes: string[]
mode_sources: Record<string, ModeSource>
segments: SkipSegment[]
episode_title: string
}
interface EpisodeData {
mal_id: number
title: string
current_episode: string
total_episodes: number
initial_mode: string
token: string
available_modes: string[]
mode_sources: Record<string, ModeSource>
segments: SkipSegment[]
episode_title: string
}
let playerInitialized = false
const initPlayer = (): void => {
@@ -66,7 +40,6 @@ const initPlayer = (): void => {
const iconPlay = container.querySelector('[data-icon-play]') as SVGElement
const iconPause = container.querySelector('[data-icon-pause]') as SVGElement
const muteBtn = container.querySelector('[data-mute]') as HTMLButtonElement
const volumeWrap = container.querySelector('[data-volume-wrap]') as HTMLElement
const volumePanel = container.querySelector('[data-volume-panel]') as HTMLElement
const volumeRange = container.querySelector('[data-volume-range]') as HTMLInputElement
const iconVolume = container.querySelector('[data-icon-volume]') as SVGElement
@@ -99,7 +72,9 @@ const initPlayer = (): void => {
const animeImage = container.getAttribute('data-anime-image') || ''
const animeAiring = (container.getAttribute('data-anime-airing') || '').toLowerCase() === 'true'
const safeJsonParse = <T>(raw: string | null, fallback: T): T => {
if (!raw) return fallback
if (!raw) {
return fallback
}
try {
return JSON.parse(raw) as T
} catch {
@@ -107,6 +82,12 @@ const initPlayer = (): void => {
}
}
const clearElement = (el: HTMLElement): void => {
while (el.firstChild) {
el.removeChild(el.firstChild)
}
}
let modeSources = safeJsonParse(container.getAttribute('data-mode-sources'), {} as Record<string, ModeSource>)
let availableModes = safeJsonParse(container.getAttribute('data-available-modes'), [] as string[])
const backendInitialMode = container.getAttribute('data-initial-mode') || 'dub'
@@ -140,7 +121,7 @@ const initPlayer = (): void => {
let isScrubbing = false
let lastKnownVolume = 1
let pendingSeekTime: number | null = null
let activeSkipSegment: { type: string, start: number, end: number } | null = null
let activeSkipSegment: { type: string, start: number, end: number } | null = null
let activeSubtitles: Array<{ start: number, end: number, text: string }> = []
let currentSubtitleTracks: Array<{ lang: string, label: string, url: string }> = []
@@ -277,20 +258,32 @@ const initPlayer = (): void => {
return
}
activeSegments = parsedSegments.filter((segment: { start: number, end: number, type: string }) => {
activeSegments = parsedSegments.filter((segment) => {
const start = segment.start
const end = segment.end
const segmentDuration = end - start
if (segmentDuration < minSegmentDurationSeconds || segmentDuration > maxSegmentDurationSeconds) return false
if (start < 0 || end <= start || end > bounds.duration + 1) return false
if (segmentDuration < minSegmentDurationSeconds || segmentDuration > maxSegmentDurationSeconds) {
return false
}
if (start < 0 || end <= start || end > bounds.duration + 1) {
return false
}
if (segment.type === 'op') {
if (start > maxIntroStartSeconds) return false
if (start > bounds.duration * 0.5) return false
if (start > maxIntroStartSeconds) {
return false
}
if (start > bounds.duration * 0.5) {
return false
}
return true
}
if (segment.type === 'ed') {
return start >= bounds.duration * minOutroStartRatio
}
return false
})
}
@@ -302,12 +295,16 @@ const initPlayer = (): void => {
return segment.start + boundedDelay
}
const findActiveSegment = (time: number): typeof activeSegments[number] | undefined => {
return activeSegments.find((segment) => {
const activationTime = skipActivationTime(segment)
return time >= activationTime && time < segment.end
})
}
const updateSkipButton = (currentTime: number): void => {
const currentDisplayTime = displayTimeFromAbsolute(currentTime)
const segment = activeSegments.find((item: { start: number, end: number }) => {
const activationTime = skipActivationTime(item)
return currentDisplayTime >= activationTime && currentDisplayTime < item.end
})
const segment = findActiveSegment(currentDisplayTime)
if (!segment) {
activeSkipSegment = null
@@ -333,8 +330,10 @@ const initPlayer = (): void => {
}
const renderSegments = (): void => {
if (!segmentsTrack) return
segmentsTrack.innerHTML = ''
if (!segmentsTrack) {
return
}
clearElement(segmentsTrack)
const bounds = timelineBounds()
if (bounds.duration <= 0) return
@@ -396,7 +395,6 @@ const initPlayer = (): void => {
break
}
}
// If we couldn't find a range containing current time (can happen immediately after seeking),
// fallback to the highest buffered end that is greater than current time
if (bufferedEnd === 0) {
@@ -573,12 +571,14 @@ const initPlayer = (): void => {
},
keepalive: true,
body: payload,
}).catch(() => {})
}).catch(() => { })
}
const parseVttTime = (raw: string): number => {
const parts = raw.trim().split(':')
if (parts.length < 2) return 0
if (parts.length < 2) {
return 0
}
const secPart = parts.pop() || '0'
const minPart = parts.pop() || '0'
const hourPart = parts.pop() || '0'
@@ -588,31 +588,58 @@ const initPlayer = (): void => {
return (hours * 3600) + (minutes * 60) + seconds
}
const parseVtt = (text: string): Array<{ start: number, end: number, text: string }> => {
const parseVttCue = (timeLine: string, lines: string[], startIndex: number): { start: number; end: number; text: string } | null => {
if (!timeLine.includes('-->')) {
return null
}
const [startRaw, endRaw] = timeLine.split('-->')
const start = parseVttTime(startRaw)
const end = parseVttTime(endRaw)
const payload: string[] = []
let i = startIndex + 1
while (i < lines.length && lines[i].trim() !== '') {
payload.push(lines[i])
i += 1
}
const textContent = payload.join('\n').replace(/<[^>]+>/g, '').trim()
if (!textContent) {
return null
}
return { start, end, text: textContent }
}
const parseVtt = (text: string): Array<{ start: number; end: number; text: string }> => {
const lines = text.replace(/\r/g, '').split('\n')
const cues: Array<{ start: number, end: number, text: string }> = []
const cues: Array<{ start: number; end: number; text: string }> = []
let i = 0
while (i < lines.length) {
const line = lines[i].trim()
if (!line) { i += 1; continue }
let timeLine = line
if (!line.includes('-->') && i + 1 < lines.length) {
timeLine = lines[i + 1].trim()
if (!line) {
i += 1
continue
}
if (i + 1 < lines.length && !line.includes('-->') && lines[i + 1].includes('-->')) {
const cue = parseVttCue(lines[i + 1].trim(), lines, i + 1)
if (cue) {
cues.push(cue)
}
i += 2
continue
}
const cue = parseVttCue(line, lines, i)
if (cue) {
cues.push(cue)
}
if (!timeLine.includes('-->')) { i += 1; continue }
const [startRaw, endRaw] = timeLine.split('-->')
const start = parseVttTime(startRaw)
const end = parseVttTime(endRaw)
i += 1
const payload: string[] = []
while (i < lines.length && lines[i].trim() !== '') {
payload.push(lines[i])
i += 1
}
const textContent = payload.join('\n').replace(/<[^>]+>/g, '').trim()
if (textContent) cues.push({ start, end, text: textContent })
}
return cues
}
@@ -634,26 +661,38 @@ const initPlayer = (): void => {
subtitleText.classList.add('hidden')
}
const findActiveCue = (time: number): { start: number; end: number; text: string } | undefined => {
return activeSubtitles.find((item) => {
return time >= item.start && time <= item.end
})
}
const updateSubtitleRender = (currentTime: number): void => {
if (!subtitleText) return
if (!subtitleText) {
return
}
if (!activeSubtitles.length) {
hideSubtitleText()
return
}
const cue = activeSubtitles.find(item => currentTime >= item.start && currentTime <= item.end)
const cue = findActiveCue(currentTime)
if (!cue) {
hideSubtitleText()
return
}
subtitleText.textContent = cue.text
subtitleText.classList.remove('hidden')
subtitleText.classList.add('block')
}
const updateSubtitleOptions = (): void => {
if (!subtitleSelect) return
if (!subtitleSelect) {
return
}
currentSubtitleTracks = subtitlesForMode(currentMode)
subtitleSelect.innerHTML = ''
clearElement(subtitleSelect)
const none = document.createElement('option')
none.value = 'none'
none.textContent = 'Off'
@@ -674,30 +713,32 @@ const initPlayer = (): void => {
}
const updateQualityOptions = (): void => {
if (!qualitySelect) return
if (!qualitySelect) {
return
}
const modeSource = modeSources[currentMode]
const qualities = modeSource?.qualities || []
qualitySelect.innerHTML = ''
clearElement(qualitySelect)
const best = document.createElement('option')
best.value = 'best'
best.textContent = 'Auto / Best'
qualitySelect.appendChild(best)
qualities.forEach(q => {
const option = document.createElement('option')
option.value = q
option.textContent = q
qualitySelect.appendChild(option)
})
const preferred = getPreferredQuality()
if (qualities.includes(preferred)) {
qualitySelect.value = preferred
} else {
qualitySelect.value = 'best'
}
const wrapper = qualitySelect.parentElement
if (wrapper) {
wrapper.classList.toggle('hidden', qualities.length === 0)
@@ -736,7 +777,7 @@ const initPlayer = (): void => {
video.src = nextURL
video.load()
pendingSeekTime = previousTime
if (wasPlaying) video.play().catch(() => {})
if (wasPlaying) video.play().catch(() => { })
updateSubtitleOptions()
updateQualityOptions()
updateModeButtons(currentMode)
@@ -834,7 +875,7 @@ const initPlayer = (): void => {
video.src = nextURL
video.load()
pendingSeekTime = previousTime
if (wasPlaying) video.play().catch(() => {})
if (wasPlaying) video.play().catch(() => { })
}
// Initialize
@@ -862,17 +903,17 @@ const initPlayer = (): void => {
if (nextStart > 0) {
try {
video.currentTime = nextStart
} catch {}
} catch { }
}
}
if (pendingSeekTime !== null && Number.isFinite(pendingSeekTime)) {
try {
video.currentTime = absoluteTimeFromDisplay(pendingSeekTime)
} catch {}
} catch { }
pendingSeekTime = null
}
if (shouldAutoPlay) {
video.play().catch(() => {})
video.play().catch(() => { })
}
updateTimeline(video.currentTime)
updateSkipButton(video.currentTime)
@@ -919,8 +960,6 @@ const initPlayer = (): void => {
})
}
const goToNextEpisode = async (): Promise<void> => {
const currentEpNum = Number.parseInt(currentEpisode, 10)
if (Number.isNaN(currentEpNum)) return
@@ -968,7 +1007,7 @@ const initPlayer = (): void => {
const wasPlaying = video.ended || !video.paused
video.src = streamUrl
video.load()
if (wasPlaying) video.play().catch(() => {})
if (wasPlaying) video.play().catch(() => { })
currentEpisode = String(nextEpisode)
pendingSeekTime = null
@@ -1032,7 +1071,7 @@ const initPlayer = (): void => {
}
}
const completeAnime = async (episodeNumber: number): Promise<void> => {
if (completionSent) return
@@ -1067,7 +1106,11 @@ const initPlayer = (): void => {
const dropdownTrigger = document.querySelector('[data-dropdown-trigger]') as HTMLButtonElement | null
if (dropdownTrigger) {
dropdownTrigger.innerHTML = 'Completed <span class="text-xs">▾</span>'
dropdownTrigger.textContent = 'Completed '
const caret = document.createElement('span')
caret.className = 'text-xs'
caret.textContent = '▾'
dropdownTrigger.appendChild(caret)
}
const watchStatusDropdown = document.getElementById('watch-status-dropdown')
@@ -1098,7 +1141,7 @@ const initPlayer = (): void => {
wrapper.id = 'watch-status-dropdown'
wrapper.innerHTML = DOMPurify.sanitize(html)
watchStatusDropdown.replaceWith(wrapper)
}).catch(() => {})
}).catch(() => { })
}
} catch {
completionSent = false
@@ -1272,24 +1315,24 @@ const initPlayer = (): void => {
document.addEventListener('keydown', (event) => {
const target = event.target as HTMLElement
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return
// Key codes
const code = event.code
const key = event.key
// Space or K: Toggle Play/Pause
if (code === 'Space' || code === 'KeyK') {
event.preventDefault()
togglePlayPause()
showControls()
}
// ArrowLeft or J: Seek Backward 10s
if (code === 'ArrowLeft' || code === 'KeyJ') {
event.preventDefault()
seekBy(-10)
}
// ArrowRight or L: Seek Forward 10s
if (code === 'ArrowRight' || code === 'KeyL') {
event.preventDefault()
@@ -1313,7 +1356,7 @@ const initPlayer = (): void => {
video.muted = nextVolume === 0
showControls()
}
// KeyM: Toggle Mute
if (code === 'KeyM') {
event.preventDefault()
@@ -1327,7 +1370,7 @@ const initPlayer = (): void => {
}
showControls()
}
// KeyF: Toggle Fullscreen
if (code === 'KeyF') {
event.preventDefault()
@@ -1381,7 +1424,6 @@ const initPlayer = (): void => {
if (episodeGrid) {
episodeGrid.querySelectorAll('[data-episode-id]').forEach((el) => {
el.classList.remove('ring-2', 'ring-accent', 'text-accent')
const epNum = parseInt(el.getAttribute('data-episode-id') || '0', 10)
const isCurrent = el.classList.contains('bg-accent/20')
if (!isCurrent) {
el.classList.remove('bg-accent/20')

View File

@@ -88,7 +88,7 @@ const renderQuickSearchResults = (query: string, results: QuickSearchResult[]):
title.textContent = 'Anime'
searchResults.appendChild(title)
results.forEach((result: QuickSearchResult): void => {
results.forEach((result: QuickSearchResult) => {
searchResults.appendChild(buildSearchResultItem(result))
})
@@ -104,10 +104,10 @@ const renderQuickSearchResults = (query: string, results: QuickSearchResult[]):
const fetchAndRenderQuickSearch = (query: string): void => {
fetch('/api/search-quick?q=' + encodeURIComponent(query))
.then((res: Response) => res.json())
.then((results: QuickSearchResult[]): void => {
.then((results: QuickSearchResult[]) => {
renderQuickSearchResults(query, results)
})
.catch((err: unknown): void => {
.catch((err: unknown) => {
console.error('Search error:', err)
})
}
@@ -128,13 +128,13 @@ const onSearchInput = (event: Event): void => {
return
}
searchTimeout = window.setTimeout((): void => {
searchTimeout = window.setTimeout(() => {
fetchAndRenderQuickSearch(query)
}, 300)
}
const onSearchBlur = (): void => {
window.setTimeout((): void => {
window.setTimeout(() => {
clearSearchResults()
}, 200)
}

View File

@@ -1,79 +1,41 @@
type Theme = "light" | "dark";
type Theme = 'light' | 'dark'
const STORAGE_KEY = "theme";
const STORAGE_KEY = 'theme'
const getSavedTheme = (): Theme => {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === "light" || raw === "dark") return raw;
return "dark";
};
const raw = localStorage.getItem(STORAGE_KEY)
if (raw === 'light' || raw === 'dark') {
return raw
}
return 'dark'
}
const applyTheme = (theme: Theme): void => {
const html = document.documentElement;
html.setAttribute("data-theme", theme);
localStorage.setItem(STORAGE_KEY, theme);
updateToggleButtons(theme);
};
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem(STORAGE_KEY, theme)
}
const cycleTheme = (): void => {
const current = getSavedTheme();
const next: Theme = current === "light" ? "dark" : "light";
applyTheme(next);
};
const updateToggleButtons = (theme: Theme): void => {
const headerBtn = document.getElementById(
"theme-toggle",
) as HTMLButtonElement | null;
const footerBtn = document.getElementById(
"footer-theme-toggle",
) as HTMLButtonElement | null;
const updateButton = (btn: HTMLButtonElement | null): void => {
if (!btn) return;
const label = btn.querySelector("[data-theme-label]") as HTMLElement | null;
if (label) {
label.textContent = theme;
}
const svg = btn.querySelector("svg");
if (!svg) return;
if (theme === "light") {
svg.innerHTML =
'<circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>';
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("fill", "none");
} else {
svg.innerHTML =
'<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>';
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("fill", "none");
}
};
updateButton(headerBtn);
updateButton(footerBtn);
};
const current = getSavedTheme()
const next: Theme = current === 'light' ? 'dark' : 'light'
applyTheme(next)
}
const initTheme = (): void => {
const saved = getSavedTheme();
applyTheme(saved);
const saved = getSavedTheme()
applyTheme(saved)
// Use event delegation to handle theme toggles
document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
const btn = target.closest("#theme-toggle, #footer-theme-toggle") as HTMLButtonElement | null;
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement
const btn = target.closest('#theme-toggle, #footer-theme-toggle') as HTMLButtonElement | null
if (btn) {
cycleTheme();
cycleTheme()
}
});
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initTheme);
} else {
initTheme();
})
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTheme)
} else {
initTheme()
}

View File

@@ -239,7 +239,7 @@ const updateNode = (node: Element, localOffsetMinutes: number): void => {
const updateAll = (): void => {
const localOffsetMinutes = -new Date().getTimezoneOffset()
const nodes = document.querySelectorAll('[data-jst-text]')
nodes.forEach((node: Element): void => updateNode(node, localOffsetMinutes))
nodes.forEach((node) => updateNode(node, localOffsetMinutes))
}
const initTimezoneConversion = (): void => {

View File

@@ -1,43 +1,55 @@
export {}
interface ToastOptions {
message: string;
duration?: number;
message: string
duration?: number
}
const toastContainer = () => {
let container = document.getElementById('toast-container');
const toastContainer = (): HTMLElement => {
let container = document.getElementById('toast-container')
if (!container) {
container = document.createElement('div');
container.id = 'toast-container';
container.className = 'fixed bottom-4 right-4 z-100 flex flex-col gap-2';
document.body.appendChild(container);
container = document.createElement('div')
container.id = 'toast-container'
container.className = 'fixed bottom-4 right-4 z-100 flex flex-col gap-2'
document.body.appendChild(container)
}
return container;
};
return container
}
const showToast = ({ message, duration = 3000 }: ToastOptions) => {
const container = toastContainer();
const toast = document.createElement('div');
toast.className = 'bg-white/10 border border-white/20 flex items-center gap-3 px-4 py-3 shadow-lg transform transition-all duration-300 translate-y-2 opacity-0';
toast.innerHTML = `
<span class="text-sm text-neutral-200">${message}</span>
<button class="ml-2 opacity-50 hover:opacity-100" onclick="this.parentElement.remove()">
<svg class="size-4 text-neutral-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
`;
const showToast = ({ message, duration = 3000 }: ToastOptions): void => {
const container = toastContainer()
const template = document.getElementById('toast-template') as HTMLTemplateElement | null
container.appendChild(toast);
if (!template) {
return
}
const toast = template.content.cloneNode(true) as HTMLElement
const messageEl = toast.querySelector('.toast-message')
const closeBtn = toast.querySelector('.toast-close')
if (messageEl) {
messageEl.textContent = message
}
closeBtn?.addEventListener('click', () => toast.remove())
container.appendChild(toast)
requestAnimationFrame(() => {
toast.classList.remove('translate-y-2', 'opacity-0');
});
toast.classList.remove('translate-y-2', 'opacity-0')
})
setTimeout(() => {
toast.classList.add('translate-y-2', 'opacity-0');
setTimeout(() => toast.remove(), 300);
}, duration);
};
toast.classList.add('translate-y-2', 'opacity-0')
setTimeout(() => toast.remove(), 300)
}, duration)
}
(window as unknown as Record<string, unknown>).showToast = showToast;
declare global {
interface Window {
showToast: typeof showToast
}
}
export { showToast };
window.showToast = showToast