maintainability #30
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
135
src/components/tags/MediaTagPanel.tsx
Normal file
135
src/components/tags/MediaTagPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user