fix: writing bit stuff cleaner
This commit is contained in:
@@ -1,11 +1,15 @@
|
|||||||
const dedupe = (): void => {
|
const dedupe = (): void => {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const elements = document.querySelectorAll('[data-id]')
|
const elements = document.querySelectorAll('[data-id]')
|
||||||
|
|
||||||
elements.forEach((item) => {
|
elements.forEach((item) => {
|
||||||
const id = item.getAttribute('data-id')
|
const id = item.getAttribute('data-id')
|
||||||
if (id && seen.has(id)) {
|
if (!id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (seen.has(id)) {
|
||||||
item.remove()
|
item.remove()
|
||||||
} else if (id) {
|
} else {
|
||||||
seen.add(id)
|
seen.add(id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -13,8 +17,9 @@ const dedupe = (): void => {
|
|||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', dedupe)
|
document.addEventListener('DOMContentLoaded', dedupe)
|
||||||
} else {
|
} else {
|
||||||
dedupe()
|
dedupe()
|
||||||
}
|
}
|
||||||
// Also run on window load to be sure
|
|
||||||
|
window.addEventListener('load', dedupe)
|
||||||
window.addEventListener('load', dedupe)
|
window.addEventListener('load', dedupe)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const setActiveDiscoverTab = (clickedTab: Element): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const triggers = group.querySelectorAll('[data-tab-trigger]')
|
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 activeClasses = parseClassList(tab.getAttribute('data-tab-active-classes'))
|
||||||
const inactiveClasses = parseClassList(tab.getAttribute('data-tab-inactive-classes'))
|
const inactiveClasses = parseClassList(tab.getAttribute('data-tab-inactive-classes'))
|
||||||
tab.classList.remove(...activeClasses)
|
tab.classList.remove(...activeClasses)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class UIDropdown extends HTMLElement {
|
|||||||
this.handleClickOutside = this.handleClickOutside.bind(this)
|
this.handleClickOutside = this.handleClickOutside.bind(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback(): void {
|
||||||
const trigger = this.querySelector('[data-trigger]')
|
const trigger = this.querySelector('[data-trigger]')
|
||||||
this.contentEl = this.querySelector('[data-content]')
|
this.contentEl = this.querySelector('[data-content]')
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ class UIDropdown extends HTMLElement {
|
|||||||
document.addEventListener('click', this.handleClickOutside)
|
document.addEventListener('click', this.handleClickOutside)
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback(): void {
|
||||||
const trigger = this.querySelector('[data-trigger]')
|
const trigger = this.querySelector('[data-trigger]')
|
||||||
if (trigger) {
|
if (trigger) {
|
||||||
trigger.removeEventListener('click', this.toggle)
|
trigger.removeEventListener('click', this.toggle)
|
||||||
@@ -28,8 +28,10 @@ class UIDropdown extends HTMLElement {
|
|||||||
document.removeEventListener('click', this.handleClickOutside)
|
document.removeEventListener('click', this.handleClickOutside)
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle() {
|
toggle(): void {
|
||||||
if (this.isClosing) return
|
if (this.isClosing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
this.isOpen = !this.isOpen
|
this.isOpen = !this.isOpen
|
||||||
if (this.contentEl) {
|
if (this.contentEl) {
|
||||||
if (this.isOpen) {
|
if (this.isOpen) {
|
||||||
@@ -40,8 +42,10 @@ class UIDropdown extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close(): void {
|
||||||
if (this.isClosing) return
|
if (this.isClosing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
this.isClosing = true
|
this.isClosing = true
|
||||||
this.isOpen = false
|
this.isOpen = false
|
||||||
if (this.contentEl) {
|
if (this.contentEl) {
|
||||||
@@ -52,7 +56,7 @@ class UIDropdown extends HTMLElement {
|
|||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
handleClickOutside(event: MouseEvent) {
|
handleClickOutside(event: MouseEvent): void {
|
||||||
if (!this.contains(event.target as Node)) {
|
if (!this.contains(event.target as Node)) {
|
||||||
this.close()
|
this.close()
|
||||||
}
|
}
|
||||||
|
|||||||
220
static/player.ts
220
static/player.ts
@@ -2,7 +2,7 @@ declare const htmx: {
|
|||||||
ajax(verb: string, path: string, target: HTMLElement): Promise<void>
|
ajax(verb: string, path: string, target: HTMLElement): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export {}
|
export { }
|
||||||
|
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
|
|
||||||
@@ -23,32 +23,6 @@ interface SkipSegment {
|
|||||||
end: number
|
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
|
let playerInitialized = false
|
||||||
|
|
||||||
const initPlayer = (): void => {
|
const initPlayer = (): void => {
|
||||||
@@ -66,7 +40,6 @@ const initPlayer = (): void => {
|
|||||||
const iconPlay = container.querySelector('[data-icon-play]') as SVGElement
|
const iconPlay = container.querySelector('[data-icon-play]') as SVGElement
|
||||||
const iconPause = container.querySelector('[data-icon-pause]') as SVGElement
|
const iconPause = container.querySelector('[data-icon-pause]') as SVGElement
|
||||||
const muteBtn = container.querySelector('[data-mute]') as HTMLButtonElement
|
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 volumePanel = container.querySelector('[data-volume-panel]') as HTMLElement
|
||||||
const volumeRange = container.querySelector('[data-volume-range]') as HTMLInputElement
|
const volumeRange = container.querySelector('[data-volume-range]') as HTMLInputElement
|
||||||
const iconVolume = container.querySelector('[data-icon-volume]') as SVGElement
|
const iconVolume = container.querySelector('[data-icon-volume]') as SVGElement
|
||||||
@@ -99,7 +72,9 @@ const initPlayer = (): void => {
|
|||||||
const animeImage = container.getAttribute('data-anime-image') || ''
|
const animeImage = container.getAttribute('data-anime-image') || ''
|
||||||
const animeAiring = (container.getAttribute('data-anime-airing') || '').toLowerCase() === 'true'
|
const animeAiring = (container.getAttribute('data-anime-airing') || '').toLowerCase() === 'true'
|
||||||
const safeJsonParse = <T>(raw: string | null, fallback: T): T => {
|
const safeJsonParse = <T>(raw: string | null, fallback: T): T => {
|
||||||
if (!raw) return fallback
|
if (!raw) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.parse(raw) as T
|
return JSON.parse(raw) as T
|
||||||
} catch {
|
} 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 modeSources = safeJsonParse(container.getAttribute('data-mode-sources'), {} as Record<string, ModeSource>)
|
||||||
let availableModes = safeJsonParse(container.getAttribute('data-available-modes'), [] as string[])
|
let availableModes = safeJsonParse(container.getAttribute('data-available-modes'), [] as string[])
|
||||||
const backendInitialMode = container.getAttribute('data-initial-mode') || 'dub'
|
const backendInitialMode = container.getAttribute('data-initial-mode') || 'dub'
|
||||||
@@ -140,7 +121,7 @@ const initPlayer = (): void => {
|
|||||||
let isScrubbing = false
|
let isScrubbing = false
|
||||||
let lastKnownVolume = 1
|
let lastKnownVolume = 1
|
||||||
let pendingSeekTime: number | null = null
|
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 activeSubtitles: Array<{ start: number, end: number, text: string }> = []
|
||||||
let currentSubtitleTracks: Array<{ lang: string, label: string, url: string }> = []
|
let currentSubtitleTracks: Array<{ lang: string, label: string, url: string }> = []
|
||||||
|
|
||||||
@@ -277,20 +258,32 @@ const initPlayer = (): void => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
activeSegments = parsedSegments.filter((segment: { start: number, end: number, type: string }) => {
|
activeSegments = parsedSegments.filter((segment) => {
|
||||||
const start = segment.start
|
const start = segment.start
|
||||||
const end = segment.end
|
const end = segment.end
|
||||||
const segmentDuration = end - start
|
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 (segment.type === 'op') {
|
||||||
if (start > maxIntroStartSeconds) return false
|
if (start > maxIntroStartSeconds) {
|
||||||
if (start > bounds.duration * 0.5) return false
|
return false
|
||||||
|
}
|
||||||
|
if (start > bounds.duration * 0.5) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (segment.type === 'ed') {
|
if (segment.type === 'ed') {
|
||||||
return start >= bounds.duration * minOutroStartRatio
|
return start >= bounds.duration * minOutroStartRatio
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -302,12 +295,16 @@ const initPlayer = (): void => {
|
|||||||
return segment.start + boundedDelay
|
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 updateSkipButton = (currentTime: number): void => {
|
||||||
const currentDisplayTime = displayTimeFromAbsolute(currentTime)
|
const currentDisplayTime = displayTimeFromAbsolute(currentTime)
|
||||||
const segment = activeSegments.find((item: { start: number, end: number }) => {
|
const segment = findActiveSegment(currentDisplayTime)
|
||||||
const activationTime = skipActivationTime(item)
|
|
||||||
return currentDisplayTime >= activationTime && currentDisplayTime < item.end
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!segment) {
|
if (!segment) {
|
||||||
activeSkipSegment = null
|
activeSkipSegment = null
|
||||||
@@ -333,8 +330,10 @@ const initPlayer = (): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderSegments = (): void => {
|
const renderSegments = (): void => {
|
||||||
if (!segmentsTrack) return
|
if (!segmentsTrack) {
|
||||||
segmentsTrack.innerHTML = ''
|
return
|
||||||
|
}
|
||||||
|
clearElement(segmentsTrack)
|
||||||
|
|
||||||
const bounds = timelineBounds()
|
const bounds = timelineBounds()
|
||||||
if (bounds.duration <= 0) return
|
if (bounds.duration <= 0) return
|
||||||
@@ -396,7 +395,6 @@ const initPlayer = (): void => {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we couldn't find a range containing current time (can happen immediately after seeking),
|
// 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
|
// fallback to the highest buffered end that is greater than current time
|
||||||
if (bufferedEnd === 0) {
|
if (bufferedEnd === 0) {
|
||||||
@@ -573,12 +571,14 @@ const initPlayer = (): void => {
|
|||||||
},
|
},
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
body: payload,
|
body: payload,
|
||||||
}).catch(() => {})
|
}).catch(() => { })
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseVttTime = (raw: string): number => {
|
const parseVttTime = (raw: string): number => {
|
||||||
const parts = raw.trim().split(':')
|
const parts = raw.trim().split(':')
|
||||||
if (parts.length < 2) return 0
|
if (parts.length < 2) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
const secPart = parts.pop() || '0'
|
const secPart = parts.pop() || '0'
|
||||||
const minPart = parts.pop() || '0'
|
const minPart = parts.pop() || '0'
|
||||||
const hourPart = parts.pop() || '0'
|
const hourPart = parts.pop() || '0'
|
||||||
@@ -588,31 +588,58 @@ const initPlayer = (): void => {
|
|||||||
return (hours * 3600) + (minutes * 60) + seconds
|
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 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
|
let i = 0
|
||||||
|
|
||||||
while (i < lines.length) {
|
while (i < lines.length) {
|
||||||
const line = lines[i].trim()
|
const line = lines[i].trim()
|
||||||
if (!line) { i += 1; continue }
|
if (!line) {
|
||||||
let timeLine = line
|
|
||||||
if (!line.includes('-->') && i + 1 < lines.length) {
|
|
||||||
timeLine = lines[i + 1].trim()
|
|
||||||
i += 1
|
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
|
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
|
return cues
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,26 +661,38 @@ const initPlayer = (): void => {
|
|||||||
subtitleText.classList.add('hidden')
|
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 => {
|
const updateSubtitleRender = (currentTime: number): void => {
|
||||||
if (!subtitleText) return
|
if (!subtitleText) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!activeSubtitles.length) {
|
if (!activeSubtitles.length) {
|
||||||
hideSubtitleText()
|
hideSubtitleText()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const cue = activeSubtitles.find(item => currentTime >= item.start && currentTime <= item.end)
|
|
||||||
|
const cue = findActiveCue(currentTime)
|
||||||
if (!cue) {
|
if (!cue) {
|
||||||
hideSubtitleText()
|
hideSubtitleText()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
subtitleText.textContent = cue.text
|
subtitleText.textContent = cue.text
|
||||||
subtitleText.classList.remove('hidden')
|
subtitleText.classList.remove('hidden')
|
||||||
subtitleText.classList.add('block')
|
subtitleText.classList.add('block')
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateSubtitleOptions = (): void => {
|
const updateSubtitleOptions = (): void => {
|
||||||
if (!subtitleSelect) return
|
if (!subtitleSelect) {
|
||||||
|
return
|
||||||
|
}
|
||||||
currentSubtitleTracks = subtitlesForMode(currentMode)
|
currentSubtitleTracks = subtitlesForMode(currentMode)
|
||||||
subtitleSelect.innerHTML = ''
|
clearElement(subtitleSelect)
|
||||||
const none = document.createElement('option')
|
const none = document.createElement('option')
|
||||||
none.value = 'none'
|
none.value = 'none'
|
||||||
none.textContent = 'Off'
|
none.textContent = 'Off'
|
||||||
@@ -674,30 +713,32 @@ const initPlayer = (): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateQualityOptions = (): void => {
|
const updateQualityOptions = (): void => {
|
||||||
if (!qualitySelect) return
|
if (!qualitySelect) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const modeSource = modeSources[currentMode]
|
const modeSource = modeSources[currentMode]
|
||||||
const qualities = modeSource?.qualities || []
|
const qualities = modeSource?.qualities || []
|
||||||
|
|
||||||
qualitySelect.innerHTML = ''
|
clearElement(qualitySelect)
|
||||||
const best = document.createElement('option')
|
const best = document.createElement('option')
|
||||||
best.value = 'best'
|
best.value = 'best'
|
||||||
best.textContent = 'Auto / Best'
|
best.textContent = 'Auto / Best'
|
||||||
qualitySelect.appendChild(best)
|
qualitySelect.appendChild(best)
|
||||||
|
|
||||||
qualities.forEach(q => {
|
qualities.forEach(q => {
|
||||||
const option = document.createElement('option')
|
const option = document.createElement('option')
|
||||||
option.value = q
|
option.value = q
|
||||||
option.textContent = q
|
option.textContent = q
|
||||||
qualitySelect.appendChild(option)
|
qualitySelect.appendChild(option)
|
||||||
})
|
})
|
||||||
|
|
||||||
const preferred = getPreferredQuality()
|
const preferred = getPreferredQuality()
|
||||||
if (qualities.includes(preferred)) {
|
if (qualities.includes(preferred)) {
|
||||||
qualitySelect.value = preferred
|
qualitySelect.value = preferred
|
||||||
} else {
|
} else {
|
||||||
qualitySelect.value = 'best'
|
qualitySelect.value = 'best'
|
||||||
}
|
}
|
||||||
|
|
||||||
const wrapper = qualitySelect.parentElement
|
const wrapper = qualitySelect.parentElement
|
||||||
if (wrapper) {
|
if (wrapper) {
|
||||||
wrapper.classList.toggle('hidden', qualities.length === 0)
|
wrapper.classList.toggle('hidden', qualities.length === 0)
|
||||||
@@ -736,7 +777,7 @@ const initPlayer = (): void => {
|
|||||||
video.src = nextURL
|
video.src = nextURL
|
||||||
video.load()
|
video.load()
|
||||||
pendingSeekTime = previousTime
|
pendingSeekTime = previousTime
|
||||||
if (wasPlaying) video.play().catch(() => {})
|
if (wasPlaying) video.play().catch(() => { })
|
||||||
updateSubtitleOptions()
|
updateSubtitleOptions()
|
||||||
updateQualityOptions()
|
updateQualityOptions()
|
||||||
updateModeButtons(currentMode)
|
updateModeButtons(currentMode)
|
||||||
@@ -834,7 +875,7 @@ const initPlayer = (): void => {
|
|||||||
video.src = nextURL
|
video.src = nextURL
|
||||||
video.load()
|
video.load()
|
||||||
pendingSeekTime = previousTime
|
pendingSeekTime = previousTime
|
||||||
if (wasPlaying) video.play().catch(() => {})
|
if (wasPlaying) video.play().catch(() => { })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
@@ -862,17 +903,17 @@ const initPlayer = (): void => {
|
|||||||
if (nextStart > 0) {
|
if (nextStart > 0) {
|
||||||
try {
|
try {
|
||||||
video.currentTime = nextStart
|
video.currentTime = nextStart
|
||||||
} catch {}
|
} catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (pendingSeekTime !== null && Number.isFinite(pendingSeekTime)) {
|
if (pendingSeekTime !== null && Number.isFinite(pendingSeekTime)) {
|
||||||
try {
|
try {
|
||||||
video.currentTime = absoluteTimeFromDisplay(pendingSeekTime)
|
video.currentTime = absoluteTimeFromDisplay(pendingSeekTime)
|
||||||
} catch {}
|
} catch { }
|
||||||
pendingSeekTime = null
|
pendingSeekTime = null
|
||||||
}
|
}
|
||||||
if (shouldAutoPlay) {
|
if (shouldAutoPlay) {
|
||||||
video.play().catch(() => {})
|
video.play().catch(() => { })
|
||||||
}
|
}
|
||||||
updateTimeline(video.currentTime)
|
updateTimeline(video.currentTime)
|
||||||
updateSkipButton(video.currentTime)
|
updateSkipButton(video.currentTime)
|
||||||
@@ -919,8 +960,6 @@ const initPlayer = (): void => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const goToNextEpisode = async (): Promise<void> => {
|
const goToNextEpisode = async (): Promise<void> => {
|
||||||
const currentEpNum = Number.parseInt(currentEpisode, 10)
|
const currentEpNum = Number.parseInt(currentEpisode, 10)
|
||||||
if (Number.isNaN(currentEpNum)) return
|
if (Number.isNaN(currentEpNum)) return
|
||||||
@@ -968,7 +1007,7 @@ const initPlayer = (): void => {
|
|||||||
const wasPlaying = video.ended || !video.paused
|
const wasPlaying = video.ended || !video.paused
|
||||||
video.src = streamUrl
|
video.src = streamUrl
|
||||||
video.load()
|
video.load()
|
||||||
if (wasPlaying) video.play().catch(() => {})
|
if (wasPlaying) video.play().catch(() => { })
|
||||||
|
|
||||||
currentEpisode = String(nextEpisode)
|
currentEpisode = String(nextEpisode)
|
||||||
pendingSeekTime = null
|
pendingSeekTime = null
|
||||||
@@ -1032,7 +1071,7 @@ const initPlayer = (): void => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const completeAnime = async (episodeNumber: number): Promise<void> => {
|
const completeAnime = async (episodeNumber: number): Promise<void> => {
|
||||||
if (completionSent) return
|
if (completionSent) return
|
||||||
@@ -1067,7 +1106,11 @@ const initPlayer = (): void => {
|
|||||||
|
|
||||||
const dropdownTrigger = document.querySelector('[data-dropdown-trigger]') as HTMLButtonElement | null
|
const dropdownTrigger = document.querySelector('[data-dropdown-trigger]') as HTMLButtonElement | null
|
||||||
if (dropdownTrigger) {
|
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')
|
const watchStatusDropdown = document.getElementById('watch-status-dropdown')
|
||||||
@@ -1098,7 +1141,7 @@ const initPlayer = (): void => {
|
|||||||
wrapper.id = 'watch-status-dropdown'
|
wrapper.id = 'watch-status-dropdown'
|
||||||
wrapper.innerHTML = DOMPurify.sanitize(html)
|
wrapper.innerHTML = DOMPurify.sanitize(html)
|
||||||
watchStatusDropdown.replaceWith(wrapper)
|
watchStatusDropdown.replaceWith(wrapper)
|
||||||
}).catch(() => {})
|
}).catch(() => { })
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
completionSent = false
|
completionSent = false
|
||||||
@@ -1272,24 +1315,24 @@ const initPlayer = (): void => {
|
|||||||
document.addEventListener('keydown', (event) => {
|
document.addEventListener('keydown', (event) => {
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return
|
||||||
|
|
||||||
// Key codes
|
// Key codes
|
||||||
const code = event.code
|
const code = event.code
|
||||||
const key = event.key
|
const key = event.key
|
||||||
|
|
||||||
// Space or K: Toggle Play/Pause
|
// Space or K: Toggle Play/Pause
|
||||||
if (code === 'Space' || code === 'KeyK') {
|
if (code === 'Space' || code === 'KeyK') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
togglePlayPause()
|
togglePlayPause()
|
||||||
showControls()
|
showControls()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArrowLeft or J: Seek Backward 10s
|
// ArrowLeft or J: Seek Backward 10s
|
||||||
if (code === 'ArrowLeft' || code === 'KeyJ') {
|
if (code === 'ArrowLeft' || code === 'KeyJ') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
seekBy(-10)
|
seekBy(-10)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArrowRight or L: Seek Forward 10s
|
// ArrowRight or L: Seek Forward 10s
|
||||||
if (code === 'ArrowRight' || code === 'KeyL') {
|
if (code === 'ArrowRight' || code === 'KeyL') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -1313,7 +1356,7 @@ const initPlayer = (): void => {
|
|||||||
video.muted = nextVolume === 0
|
video.muted = nextVolume === 0
|
||||||
showControls()
|
showControls()
|
||||||
}
|
}
|
||||||
|
|
||||||
// KeyM: Toggle Mute
|
// KeyM: Toggle Mute
|
||||||
if (code === 'KeyM') {
|
if (code === 'KeyM') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -1327,7 +1370,7 @@ const initPlayer = (): void => {
|
|||||||
}
|
}
|
||||||
showControls()
|
showControls()
|
||||||
}
|
}
|
||||||
|
|
||||||
// KeyF: Toggle Fullscreen
|
// KeyF: Toggle Fullscreen
|
||||||
if (code === 'KeyF') {
|
if (code === 'KeyF') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -1381,7 +1424,6 @@ const initPlayer = (): void => {
|
|||||||
if (episodeGrid) {
|
if (episodeGrid) {
|
||||||
episodeGrid.querySelectorAll('[data-episode-id]').forEach((el) => {
|
episodeGrid.querySelectorAll('[data-episode-id]').forEach((el) => {
|
||||||
el.classList.remove('ring-2', 'ring-accent', 'text-accent')
|
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')
|
const isCurrent = el.classList.contains('bg-accent/20')
|
||||||
if (!isCurrent) {
|
if (!isCurrent) {
|
||||||
el.classList.remove('bg-accent/20')
|
el.classList.remove('bg-accent/20')
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ const renderQuickSearchResults = (query: string, results: QuickSearchResult[]):
|
|||||||
title.textContent = 'Anime'
|
title.textContent = 'Anime'
|
||||||
searchResults.appendChild(title)
|
searchResults.appendChild(title)
|
||||||
|
|
||||||
results.forEach((result: QuickSearchResult): void => {
|
results.forEach((result: QuickSearchResult) => {
|
||||||
searchResults.appendChild(buildSearchResultItem(result))
|
searchResults.appendChild(buildSearchResultItem(result))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -104,10 +104,10 @@ const renderQuickSearchResults = (query: string, results: QuickSearchResult[]):
|
|||||||
const fetchAndRenderQuickSearch = (query: string): void => {
|
const fetchAndRenderQuickSearch = (query: string): void => {
|
||||||
fetch('/api/search-quick?q=' + encodeURIComponent(query))
|
fetch('/api/search-quick?q=' + encodeURIComponent(query))
|
||||||
.then((res: Response) => res.json())
|
.then((res: Response) => res.json())
|
||||||
.then((results: QuickSearchResult[]): void => {
|
.then((results: QuickSearchResult[]) => {
|
||||||
renderQuickSearchResults(query, results)
|
renderQuickSearchResults(query, results)
|
||||||
})
|
})
|
||||||
.catch((err: unknown): void => {
|
.catch((err: unknown) => {
|
||||||
console.error('Search error:', err)
|
console.error('Search error:', err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -128,13 +128,13 @@ const onSearchInput = (event: Event): void => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
searchTimeout = window.setTimeout((): void => {
|
searchTimeout = window.setTimeout(() => {
|
||||||
fetchAndRenderQuickSearch(query)
|
fetchAndRenderQuickSearch(query)
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSearchBlur = (): void => {
|
const onSearchBlur = (): void => {
|
||||||
window.setTimeout((): void => {
|
window.setTimeout(() => {
|
||||||
clearSearchResults()
|
clearSearchResults()
|
||||||
}, 200)
|
}, 200)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +1,41 @@
|
|||||||
type Theme = "light" | "dark";
|
type Theme = 'light' | 'dark'
|
||||||
|
|
||||||
const STORAGE_KEY = "theme";
|
const STORAGE_KEY = 'theme'
|
||||||
|
|
||||||
const getSavedTheme = (): Theme => {
|
const getSavedTheme = (): Theme => {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
if (raw === "light" || raw === "dark") return raw;
|
if (raw === 'light' || raw === 'dark') {
|
||||||
return "dark";
|
return raw
|
||||||
};
|
}
|
||||||
|
return 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
const applyTheme = (theme: Theme): void => {
|
const applyTheme = (theme: Theme): void => {
|
||||||
const html = document.documentElement;
|
document.documentElement.setAttribute('data-theme', theme)
|
||||||
html.setAttribute("data-theme", theme);
|
localStorage.setItem(STORAGE_KEY, theme)
|
||||||
localStorage.setItem(STORAGE_KEY, theme);
|
}
|
||||||
updateToggleButtons(theme);
|
|
||||||
};
|
|
||||||
|
|
||||||
const cycleTheme = (): void => {
|
const cycleTheme = (): void => {
|
||||||
const current = getSavedTheme();
|
const current = getSavedTheme()
|
||||||
const next: Theme = current === "light" ? "dark" : "light";
|
const next: Theme = current === 'light' ? 'dark' : 'light'
|
||||||
applyTheme(next);
|
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 initTheme = (): void => {
|
const initTheme = (): void => {
|
||||||
const saved = getSavedTheme();
|
const saved = getSavedTheme()
|
||||||
applyTheme(saved);
|
applyTheme(saved)
|
||||||
|
|
||||||
// Use event delegation to handle theme toggles
|
document.addEventListener('click', (e) => {
|
||||||
document.addEventListener("click", (e) => {
|
const target = e.target as HTMLElement
|
||||||
const target = e.target as HTMLElement;
|
const btn = target.closest('#theme-toggle, #footer-theme-toggle') as HTMLButtonElement | null
|
||||||
const btn = target.closest("#theme-toggle, #footer-theme-toggle") as HTMLButtonElement | null;
|
|
||||||
|
|
||||||
if (btn) {
|
if (btn) {
|
||||||
cycleTheme();
|
cycleTheme()
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener("DOMContentLoaded", initTheme);
|
document.addEventListener('DOMContentLoaded', initTheme)
|
||||||
} else {
|
} else {
|
||||||
initTheme();
|
initTheme()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ const updateNode = (node: Element, localOffsetMinutes: number): void => {
|
|||||||
const updateAll = (): void => {
|
const updateAll = (): void => {
|
||||||
const localOffsetMinutes = -new Date().getTimezoneOffset()
|
const localOffsetMinutes = -new Date().getTimezoneOffset()
|
||||||
const nodes = document.querySelectorAll('[data-jst-text]')
|
const nodes = document.querySelectorAll('[data-jst-text]')
|
||||||
nodes.forEach((node: Element): void => updateNode(node, localOffsetMinutes))
|
nodes.forEach((node) => updateNode(node, localOffsetMinutes))
|
||||||
}
|
}
|
||||||
|
|
||||||
const initTimezoneConversion = (): void => {
|
const initTimezoneConversion = (): void => {
|
||||||
|
|||||||
@@ -1,43 +1,55 @@
|
|||||||
|
export {}
|
||||||
|
|
||||||
interface ToastOptions {
|
interface ToastOptions {
|
||||||
message: string;
|
message: string
|
||||||
duration?: number;
|
duration?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const toastContainer = () => {
|
const toastContainer = (): HTMLElement => {
|
||||||
let container = document.getElementById('toast-container');
|
let container = document.getElementById('toast-container')
|
||||||
if (!container) {
|
if (!container) {
|
||||||
container = document.createElement('div');
|
container = document.createElement('div')
|
||||||
container.id = 'toast-container';
|
container.id = 'toast-container'
|
||||||
container.className = 'fixed bottom-4 right-4 z-100 flex flex-col gap-2';
|
container.className = 'fixed bottom-4 right-4 z-100 flex flex-col gap-2'
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container)
|
||||||
}
|
}
|
||||||
return container;
|
return container
|
||||||
};
|
}
|
||||||
|
|
||||||
const showToast = ({ message, duration = 3000 }: ToastOptions) => {
|
const showToast = ({ message, duration = 3000 }: ToastOptions): void => {
|
||||||
const container = toastContainer();
|
const container = toastContainer()
|
||||||
const toast = document.createElement('div');
|
const template = document.getElementById('toast-template') as HTMLTemplateElement | null
|
||||||
|
|
||||||
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>
|
|
||||||
`;
|
|
||||||
|
|
||||||
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(() => {
|
requestAnimationFrame(() => {
|
||||||
toast.classList.remove('translate-y-2', 'opacity-0');
|
toast.classList.remove('translate-y-2', 'opacity-0')
|
||||||
});
|
})
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
toast.classList.add('translate-y-2', 'opacity-0');
|
toast.classList.add('translate-y-2', 'opacity-0')
|
||||||
setTimeout(() => toast.remove(), 300);
|
setTimeout(() => toast.remove(), 300)
|
||||||
}, duration);
|
}, duration)
|
||||||
};
|
}
|
||||||
|
|
||||||
(window as unknown as Record<string, unknown>).showToast = showToast;
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
showToast: typeof showToast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export { showToast };
|
window.showToast = showToast
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
opacity: 0 !important;
|
opacity: 0 !important;
|
||||||
margin-left: 0 !important;
|
margin-left: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Re-enable transitions after initialization */
|
/* Re-enable transitions after initialization */
|
||||||
.sidebar-ready #mobile-menu,
|
.sidebar-ready #mobile-menu,
|
||||||
.sidebar-ready .nav-label-container {
|
.sidebar-ready .nav-label-container {
|
||||||
@@ -25,8 +25,24 @@
|
|||||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
transition-duration: 300ms;
|
transition-duration: 300ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Theme toggle icon visibility */
|
||||||
|
html[data-theme="dark"] .theme-icon-dark { display: none; }
|
||||||
|
html[data-theme="dark"] .theme-icon-light { display: block; }
|
||||||
|
html[data-theme="light"] .theme-icon-light { display: none; }
|
||||||
|
html[data-theme="light"] .theme-icon-dark { display: block; }
|
||||||
</style>
|
</style>
|
||||||
<script type="module" src="/dist/static/theme.js" defer></script>
|
<script type="module" src="/dist/static/theme.js" defer></script>
|
||||||
|
<template id="toast-template">
|
||||||
|
<div class="toast 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">
|
||||||
|
<span class="toast-message text-sm text-neutral-200"></span>
|
||||||
|
<button class="toast-close ml-2 opacity-50 hover:opacity-100" aria-label="Close">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<script type="module" src="/dist/static/dropdown.js" defer></script>
|
<script type="module" src="/dist/static/dropdown.js" defer></script>
|
||||||
<script type="module" src="/dist/static/discover.js" defer></script>
|
<script type="module" src="/dist/static/discover.js" defer></script>
|
||||||
<script type="module" src="/dist/static/anime.js" defer></script>
|
<script type="module" src="/dist/static/anime.js" defer></script>
|
||||||
|
|||||||
@@ -6,6 +6,19 @@
|
|||||||
<span class="text-neutral-300 font-semibold text-lg tracking-tight">MyAnimeList</span>
|
<span class="text-neutral-300 font-semibold text-lg tracking-tight">MyAnimeList</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-6">
|
<div class="flex items-center gap-6">
|
||||||
|
<button
|
||||||
|
id="footer-theme-toggle"
|
||||||
|
class="text-neutral-500 transition-colors hover:text-neutral-300 focus:outline-none"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
<svg class="theme-icon-dark size-5" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2">
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||||
|
</svg>
|
||||||
|
<svg class="theme-icon-light hidden size-5" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2">
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
<a href="https://github.com/mkelvers/mal" target="_blank" rel="noopener" class="group flex items-center gap-2.5 text-neutral-500 transition-colors hover:text-neutral-300">
|
<a href="https://github.com/mkelvers/mal" target="_blank" rel="noopener" class="group flex items-center gap-2.5 text-neutral-500 transition-colors hover:text-neutral-300">
|
||||||
<svg class="h-5 w-5 transition-transform group-hover:scale-110" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
<svg class="h-5 w-5 transition-transform group-hover:scale-110" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||||
<span class="text-sm font-medium">Source</span>
|
<span class="text-sm font-medium">Source</span>
|
||||||
|
|||||||
@@ -25,7 +25,20 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end lg:w-72 relative">
|
<div class="flex items-center gap-2 lg:w-72 relative">
|
||||||
|
<button
|
||||||
|
id="theme-toggle"
|
||||||
|
class="rounded-full p-1.5 text-neutral-400 transition-colors hover:bg-white/5 hover:text-white focus:outline-none"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
<svg class="theme-icon-dark size-5" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2">
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||||
|
</svg>
|
||||||
|
<svg class="theme-icon-light hidden size-5" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2">
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
<ui-dropdown class="relative block">
|
<ui-dropdown class="relative block">
|
||||||
<div data-trigger class="cursor-pointer">
|
<div data-trigger class="cursor-pointer">
|
||||||
<button class="flex items-center gap-1 rounded-full p-1 transition-colors hover:bg-white/5 focus:outline-none">
|
<button class="flex items-center gap-1 rounded-full p-1 transition-colors hover:bg-white/5 focus:outline-none">
|
||||||
|
|||||||
Reference in New Issue
Block a user