reduce repeated tag selector code

This commit is contained in:
Garret Patti
2026-04-18 11:10:26 -04:00
parent 152bc12427
commit 625e256944
6 changed files with 418 additions and 544 deletions

View File

@@ -2,7 +2,7 @@
import { useEffect, useRef, useState, useCallback } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import type { Game, GameFile, GamePlatform } from '@/types' import type { Game, GameFile, GamePlatform } from '@/types'
import TagSelector from '@/components/tags/TagSelector' import MediaTagPanel from '@/components/tags/MediaTagPanel'
import AssignedTagBadges from '@/components/tags/AssignedTagBadges' import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
// Import SVG icons // Import SVG icons
@@ -525,49 +525,12 @@ export default function GameDetailModal({ game, libraryId, onClose, onPrev, onNe
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */} {/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
{showTagPanel && ( {showTagPanel && (
<div <MediaTagPanel
className="flex-shrink-0 flex flex-col overflow-hidden w-full max-h-[50vh] md:w-80 md:max-h-none md:h-full"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Panel header — hide | ✕ close */}
<div className="flex items-center justify-between p-4 flex-shrink-0">
<button
onClick={() => setShowTagPanel(false)}
className={smallBtn}
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
aria-label="Hide panel"
title="Hide panel"
>
</button>
<button
onClick={onClose}
className={smallBtn}
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
aria-label="Close"
title="Close"
>
</button>
</div>
{/* Tags */}
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mt-4 mb-3" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<TagSelector
itemKey={game.item_key!} itemKey={game.item_key!}
onHide={() => setShowTagPanel(false)}
onClose={onClose}
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }} onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
refreshKey={tagRefreshKey}
/> />
</div>
</div>
)} )}
</div> </div>

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useRef, useState, useCallback } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import TagSelector from '@/components/tags/TagSelector' import MediaTagPanel from '@/components/tags/MediaTagPanel'
interface Props { interface Props {
url: string url: string
@@ -21,10 +21,6 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
const [showTagsLocal, setShowTagsLocal] = useState(false) const [showTagsLocal, setShowTagsLocal] = useState(false)
const showTags = showTagsProp ?? showTagsLocal const showTags = showTagsProp ?? showTagsLocal
const setShowTags = onShowTagsChange ?? setShowTagsLocal const setShowTags = onShowTagsChange ?? setShowTagsLocal
const [aiTagging, setAiTagging] = useState(false)
const [aiTagError, setAiTagError] = useState<string | null>(null)
const [tagRefreshKey, setTagRefreshKey] = useState(0)
// Text extraction state // Text extraction state
const [extractedText, setExtractedText] = useState<string | null>(null) const [extractedText, setExtractedText] = useState<string | null>(null)
const [translatedText, setTranslatedText] = useState<string | null>(null) const [translatedText, setTranslatedText] = useState<string | null>(null)
@@ -211,22 +207,6 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
} }
} }
const handleAiTag = async () => {
if (!onAiTag) return
setAiTagging(true)
setAiTagError(null)
try {
await onAiTag()
setTagRefreshKey((k) => k + 1)
onTagsChanged?.()
} catch (err) {
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
setTimeout(() => setAiTagError(null), 4000)
} finally {
setAiTagging(false)
}
}
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0' const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
return ( return (
@@ -369,45 +349,15 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */} {/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
{showTags && ( {showTags && (
<div <MediaTagPanel
className="flex-shrink-0 flex flex-col overflow-hidden w-full max-h-[50vh] md:w-80 md:max-h-none md:h-full" itemKey={itemKey!}
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }} onHide={() => setShowTags(false)}
onClick={(e) => e.stopPropagation()} onClose={onClose}
onTagsChanged={onTagsChanged}
onAiTag={onAiTag}
> >
{/* Panel header — hide | ✨ AI tag ✕ close */}
<div className="flex items-center justify-between p-4 flex-shrink-0">
<button
onClick={() => setShowTags(false)}
className={smallBtn}
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
aria-label="Hide panel"
title="Hide panel"
>
</button>
<div className="flex items-center gap-1.5">
<button
onClick={onClose}
className={smallBtn}
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
aria-label="Close"
title="Close"
>
</button>
</div>
</div>
{/* Scrollable panel content */}
<div className="overflow-y-auto flex-1 min-h-0 flex flex-col gap-4 px-4 pb-4">
{/* Description section */} {/* Description section */}
<div className="flex flex-col gap-1" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}> <div className="flex flex-col gap-1 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
{/* Heading row */}
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}> <p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Description Description
@@ -433,7 +383,6 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
{generatingDesc || descPending ? <span className="animate-spin" style={{ display: 'inline-block' }}></span> : '✨'} {generatingDesc || descPending ? <span className="animate-spin" style={{ display: 'inline-block' }}></span> : '✨'}
</button> </button>
</div> </div>
{/* Editable textarea */}
<textarea <textarea
value={editedDescription} value={editedDescription}
onChange={(e) => setEditedDescription(e.target.value)} onChange={(e) => setEditedDescription(e.target.value)}
@@ -475,13 +424,11 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
{/* Text extraction section — only for images */} {/* Text extraction section — only for images */}
{isImage && ( {isImage && (
<div className="flex flex-col gap-2" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}> <div className="flex flex-col gap-2 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
{/* Heading row */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}> <p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Text Extraction Text Extraction
</p> </p>
{/* AI button — forces LLM, no OCR */}
<button <button
onClick={() => callExtract('llm')} onClick={() => callExtract('llm')}
disabled={extracting || extractPending} disabled={extracting || extractPending}
@@ -504,16 +451,12 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
</button> </button>
</div> </div>
{/* OCR button row */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<button <button
onClick={() => callExtract('tesseract')} onClick={() => callExtract('tesseract')}
disabled={extracting || extractPending} disabled={extracting || extractPending}
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0" className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0"
style={{ style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
backgroundColor: 'var(--border)',
color: 'var(--text-secondary)',
}}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (!extracting && !extractPending) { if (!extracting && !extractPending) {
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)' ;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
@@ -543,9 +486,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
/> />
</div> </div>
{extractError && ( {extractError && <p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>}
<p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>
)}
{extractedText && ( {extractedText && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -671,41 +612,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
)} )}
</div> </div>
)} )}
</MediaTagPanel>
{/* Tags section */}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
<div className="flex items-center justify-between mb-3">
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
{onAiTag && (
<button
onClick={handleAiTag}
disabled={aiTagging}
className={`${smallBtn} disabled:opacity-50`}
style={{
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--border)',
color: aiTagError ? '#fca5a5' : 'var(--text-secondary)',
fontSize: '1rem',
}}
onMouseEnter={(e) => {
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
}}
onMouseLeave={(e) => {
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
}}
aria-label="AI Tag this image"
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
>
{aiTagging ? <span className="animate-spin" style={{ display: 'inline-block' }}></span> : '✨'}
</button>
)}
</div>
{aiTagError && <p className="text-xs mb-2" style={{ color: '#f87171' }}>{aiTagError}</p>}
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} hideDescription />
</div>
</div>
</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import TagSelector from '@/components/tags/TagSelector' import MediaTagPanel from '@/components/tags/MediaTagPanel'
import { useUserSettings } from '@/hooks/useUserSettings' import { useUserSettings } from '@/hooks/useUserSettings'
interface Props { interface Props {
@@ -28,9 +28,6 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
const [showTagsLocal, setShowTagsLocal] = useState(false) const [showTagsLocal, setShowTagsLocal] = useState(false)
const showTags = showTagsProp ?? showTagsLocal const showTags = showTagsProp ?? showTagsLocal
const setShowTags = onShowTagsChange ?? setShowTagsLocal const setShowTags = onShowTagsChange ?? setShowTagsLocal
const [aiTagging, setAiTagging] = useState(false)
const [aiTagError, setAiTagError] = useState<string | null>(null)
const [tagRefreshKey, setTagRefreshKey] = useState(0)
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
@@ -50,22 +47,6 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
if (e.target === overlayRef.current) onClose() if (e.target === overlayRef.current) onClose()
} }
const handleAiTag = async () => {
if (!onAiTag) return
setAiTagging(true)
setAiTagError(null)
try {
await onAiTag()
setTagRefreshKey((k) => k + 1)
onTagsChanged?.()
} catch (err) {
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
setTimeout(() => setAiTagError(null), 4000)
} finally {
setAiTagging(false)
}
}
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0' const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
return ( return (
@@ -157,72 +138,13 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
{/* ── Tag panel ── bottom half on mobile, right sidebar on desktop */} {/* ── Tag panel ── bottom half on mobile, right sidebar on desktop */}
{showTags && ( {showTags && (
<div <MediaTagPanel
className="flex-shrink-0 flex flex-col overflow-hidden w-full max-h-[50vh] md:w-80 md:max-h-none md:h-full" itemKey={itemKey!}
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }} onHide={() => setShowTags(false)}
onClick={(e) => e.stopPropagation()} onClose={onClose}
> onTagsChanged={onTagsChanged}
{/* Panel header — hide | ✕ close */} onAiTag={onAiTag}
<div className="flex items-center justify-between p-4 flex-shrink-0"> />
<button
onClick={() => setShowTags(false)}
className={smallBtn}
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
aria-label="Hide panel"
title="Hide panel"
>
</button>
<div className="flex items-center gap-1.5">
<button
onClick={onClose}
className={smallBtn}
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
aria-label="Close"
title="Close"
>
</button>
</div>
</div>
{/* Tags */}
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
<div className="flex items-center justify-between mt-4 mb-3">
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
{onAiTag && (
<button
onClick={handleAiTag}
disabled={aiTagging}
className={`${smallBtn} disabled:opacity-50`}
style={{
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--border)',
color: aiTagError ? '#fca5a5' : 'var(--text-secondary)',
fontSize: '1rem',
}}
onMouseEnter={(e) => {
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
}}
onMouseLeave={(e) => {
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
}}
aria-label="AI Tag this video"
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
>
{aiTagging ? <span className="animate-spin" style={{ display: 'inline-block' }}></span> : '✨'}
</button>
)}
</div>
{aiTagError && <p className="text-xs mb-2" style={{ color: '#f87171' }}>{aiTagError}</p>}
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} />
</div>
</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { Movie } from '@/types' import type { Movie } from '@/types'
import TagSelector from '@/components/tags/TagSelector' import MediaTagPanel from '@/components/tags/MediaTagPanel'
import AssignedTagBadges from '@/components/tags/AssignedTagBadges' import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal' import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
@@ -565,49 +565,12 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */} {/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
{showTagPanel && ( {showTagPanel && (
<div <MediaTagPanel
className="flex-shrink-0 flex flex-col overflow-hidden w-full max-h-[50vh] md:w-80 md:max-h-none md:h-full"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Panel header — hide | ✕ close */}
<div className="flex items-center justify-between p-4 flex-shrink-0">
<button
onClick={() => setShowTagPanel(false)}
className={smallBtn}
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
aria-label="Hide panel"
title="Hide panel"
>
</button>
<button
onClick={onClose}
className={smallBtn}
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
aria-label="Close"
title="Close"
>
</button>
</div>
{/* Tags */}
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
<p className="text-xs font-semibold uppercase tracking-wider mt-4 mb-3" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<TagSelector
itemKey={movie.item_key!} itemKey={movie.item_key!}
onHide={() => setShowTagPanel(false)}
onClose={onClose}
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }} onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
refreshKey={tagRefreshKey}
/> />
</div>
</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,135 @@
'use client'
import { useState } from 'react'
import TagSelector from './TagSelector'
interface Props {
itemKey: string
onHide: () => void
onClose: () => void
onTagsChanged?: () => void
externalRefreshKey?: number
onAiTag?: () => Promise<void>
disabled?: boolean
disabledMessage?: string
children?: React.ReactNode
}
const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0'
export default function MediaTagPanel({
itemKey,
onHide,
onClose,
onTagsChanged,
externalRefreshKey = 0,
onAiTag,
disabled,
disabledMessage,
children,
}: Props) {
const [aiTagging, setAiTagging] = useState(false)
const [aiTagError, setAiTagError] = useState<string | null>(null)
const [internalRefreshKey, setInternalRefreshKey] = useState(0)
const handleAiTag = async () => {
if (!onAiTag) return
setAiTagging(true)
setAiTagError(null)
try {
await onAiTag()
setInternalRefreshKey((k) => k + 1)
onTagsChanged?.()
} catch (err) {
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
setTimeout(() => setAiTagError(null), 4000)
} finally {
setAiTagging(false)
}
}
return (
<div
className="flex-shrink-0 flex flex-col overflow-hidden w-full max-h-[50vh] md:w-80 md:max-h-none md:h-full"
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Panel header — hide | ✕ close */}
<div className="flex items-center justify-between p-4 flex-shrink-0">
<button
onClick={onHide}
className={smallBtn}
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
aria-label="Hide panel"
title="Hide panel"
>
</button>
<button
onClick={onClose}
className={smallBtn}
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
aria-label="Close"
title="Close"
>
</button>
</div>
{/* Scrollable content */}
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
{children}
{disabled || !itemKey ? (
disabledMessage ? (
<p className="text-xs mt-4 italic" style={{ color: 'var(--text-secondary)' }}>
{disabledMessage}
</p>
) : null
) : (
<>
{/* Tags section heading + optional AI button */}
<div className="flex items-center justify-between mt-4 mb-3">
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
{onAiTag && (
<button
onClick={handleAiTag}
disabled={aiTagging}
className={`${smallBtn} disabled:opacity-50`}
style={{
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--border)',
color: aiTagError ? '#fca5a5' : 'var(--text-secondary)',
fontSize: '1rem',
}}
onMouseEnter={(e) => {
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
}}
onMouseLeave={(e) => {
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
}}
aria-label="AI Tag"
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
>
{aiTagging ? <span className="animate-spin" style={{ display: 'inline-block' }}></span> : '✨'}
</button>
)}
</div>
{aiTagError && <p className="text-xs mb-2" style={{ color: '#f87171' }}>{aiTagError}</p>}
<TagSelector
itemKey={itemKey}
onTagsChanged={onTagsChanged}
refreshKey={internalRefreshKey + externalRefreshKey}
hideDescription
/>
</>
)}
</div>
</div>
)
}

View File

@@ -5,6 +5,7 @@ import type { TvSeries, TvSeason, TvEpisode } from '@/types'
import FilterPanel from '@/components/FilterPanel' import FilterPanel from '@/components/FilterPanel'
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal' import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
import MediaTagPanel from '@/components/tags/MediaTagPanel'
import TagSelector from '@/components/tags/TagSelector' import TagSelector from '@/components/tags/TagSelector'
import AssignedTagBadges from '@/components/tags/AssignedTagBadges' import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
import EpisodeCard from './EpisodeCard' import EpisodeCard from './EpisodeCard'
@@ -397,6 +398,28 @@ export default function TvView({ libraryId }: Props) {
return true return true
}) })
// Arrow key navigation for series/season levels (mirrors the prev/next UI buttons)
useEffect(() => {
if (view === 'series') return
const handleArrowKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') {
if (view === 'seasons' && selectedSeriesIndex !== null && selectedSeriesIndex > 0)
openSeries(filteredSeries[selectedSeriesIndex - 1])
else if (view === 'episodes' && selectedSeasonIndex !== null && selectedSeasonIndex > 0)
openSeason(seasons[selectedSeasonIndex - 1], selectedSeasonIndex - 1)
}
if (e.key === 'ArrowRight') {
if (view === 'seasons' && selectedSeriesIndex !== null && selectedSeriesIndex < filteredSeries.length - 1)
openSeries(filteredSeries[selectedSeriesIndex + 1])
else if (view === 'episodes' && selectedSeasonIndex !== null && selectedSeasonIndex < seasons.length - 1)
openSeason(seasons[selectedSeasonIndex + 1], selectedSeasonIndex + 1)
}
}
document.addEventListener('keydown', handleArrowKey)
return () => document.removeEventListener('keydown', handleArrowKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view, selectedSeriesIndex, selectedSeasonIndex, filteredSeries, seasons])
const playingEpisode = playingEpisodeIndex !== null ? episodes[playingEpisodeIndex] ?? null : null const playingEpisode = playingEpisodeIndex !== null ? episodes[playingEpisodeIndex] ?? null : null
if (playingEpisode && playingEpisodeIndex !== null) { if (playingEpisode && playingEpisodeIndex !== null) {
@@ -1041,59 +1064,20 @@ export default function TvView({ libraryId }: Props) {
{/* Right tag panel */} {/* Right tag panel */}
{showTagPanel && ( {showTagPanel && (
<div <MediaTagPanel
className="flex-shrink-0 flex flex-col overflow-hidden w-full max-h-[50vh] md:w-80 md:max-h-none md:h-full" itemKey={tagPanelItemKey ?? ''}
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }} onHide={() => setShowTagPanel(false)}
onClick={(e) => e.stopPropagation()} onClose={goToSeries}
>
<div className="flex items-center justify-between p-4 flex-shrink-0">
<button
onClick={() => setShowTagPanel(false)}
className={smallBtn}
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
aria-label="Hide panel"
title="Hide panel"
>
</button>
<button
onClick={goToSeries}
className={smallBtn}
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
aria-label="Close"
title="Close"
>
</button>
</div>
<div className="overflow-y-auto flex-1 min-h-0 px-4 pb-4" style={{ borderTop: '1px solid var(--border)' }}>
{tagPanelDisabled ? (
<p className="text-xs mt-4 italic" style={{ color: 'var(--text-secondary)' }}>
Seasons cannot be tagged. Select an episode to tag it.
</p>
) : tagPanelItemKey ? (
<>
<p className="text-xs font-semibold uppercase tracking-wider mt-4 mb-3" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<TagSelector
itemKey={tagPanelItemKey}
onTagsChanged={() => { onTagsChanged={() => {
setTagRefreshKey((k) => k + 1) setTagRefreshKey((k) => k + 1)
setFilterRefreshKey((k) => k + 1) setFilterRefreshKey((k) => k + 1)
fetchAssignments() fetchAssignments()
fetchSeriesEpisodeTags() fetchSeriesEpisodeTags()
}} }}
refreshKey={tagRefreshKey} externalRefreshKey={tagRefreshKey}
disabled={tagPanelDisabled}
disabledMessage="Seasons cannot be tagged. Select an episode to tag it."
/> />
</>
) : null}
</div>
</div>
)} )}
</div> </div>
</div> </div>