maintainability #30
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
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 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 ── */}
|
||||
{showTagPanel && (
|
||||
<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={() => 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!}
|
||||
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
|
||||
refreshKey={tagRefreshKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MediaTagPanel
|
||||
itemKey={game.item_key!}
|
||||
onHide={() => setShowTagPanel(false)}
|
||||
onClose={onClose}
|
||||
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import TagSelector from '@/components/tags/TagSelector'
|
||||
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
||||
|
||||
interface Props {
|
||||
url: string
|
||||
@@ -21,10 +21,6 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
const [showTagsLocal, setShowTagsLocal] = useState(false)
|
||||
const showTags = showTagsProp ?? showTagsLocal
|
||||
const setShowTags = onShowTagsChange ?? setShowTagsLocal
|
||||
const [aiTagging, setAiTagging] = useState(false)
|
||||
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
||||
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
||||
|
||||
// Text extraction state
|
||||
const [extractedText, setExtractedText] = 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'
|
||||
|
||||
return (
|
||||
@@ -369,343 +349,270 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
|
||||
|
||||
{/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */}
|
||||
{showTags && (
|
||||
<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()}
|
||||
<MediaTagPanel
|
||||
itemKey={itemKey!}
|
||||
onHide={() => setShowTags(false)}
|
||||
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">
|
||||
{/* Description section */}
|
||||
<div className="flex flex-col gap-1 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||||
Description
|
||||
</p>
|
||||
<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"
|
||||
onClick={handleGenerateDescription}
|
||||
disabled={generatingDesc || descPending}
|
||||
className={`${smallBtn} disabled:opacity-50`}
|
||||
style={{
|
||||
backgroundColor: descPending ? 'var(--accent)' : 'var(--border)',
|
||||
color: descPending ? '#fff' : 'var(--text-secondary)',
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!generatingDesc && !descPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!descPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||
}}
|
||||
aria-label={aiDescription ? 'Regenerate description' : 'Generate description'}
|
||||
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
|
||||
>
|
||||
✕
|
||||
{generatingDesc || descPending ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={editedDescription}
|
||||
onChange={(e) => setEditedDescription(e.target.value)}
|
||||
placeholder="No description yet…"
|
||||
className="text-xs rounded-lg p-2 w-full resize-y outline-none"
|
||||
style={{
|
||||
backgroundColor: 'var(--background)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--text-primary)',
|
||||
minHeight: '3.5rem',
|
||||
maxHeight: '8rem',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
{editedDescription !== (aiDescription ?? '') && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
setSavingDesc(true)
|
||||
try {
|
||||
await fetch('/api/ai-tagging/fields', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ itemKey, aiDescription: editedDescription }),
|
||||
})
|
||||
setAiDescription(editedDescription)
|
||||
} finally {
|
||||
setSavingDesc(false)
|
||||
}
|
||||
}}
|
||||
disabled={savingDesc}
|
||||
className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||
>
|
||||
{savingDesc ? '⟳ Saving…' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
{descError && <span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>}
|
||||
</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 */}
|
||||
<div className="flex flex-col gap-1" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
||||
{/* Heading row */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{/* Text extraction section — only for images */}
|
||||
{isImage && (
|
||||
<div className="flex flex-col gap-2 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||||
Description
|
||||
Text Extraction
|
||||
</p>
|
||||
<button
|
||||
onClick={handleGenerateDescription}
|
||||
disabled={generatingDesc || descPending}
|
||||
onClick={() => callExtract('llm')}
|
||||
disabled={extracting || extractPending}
|
||||
className={`${smallBtn} disabled:opacity-50`}
|
||||
style={{
|
||||
backgroundColor: descPending ? 'var(--accent)' : 'var(--border)',
|
||||
color: descPending ? '#fff' : 'var(--text-secondary)',
|
||||
backgroundColor: extractPending ? 'var(--accent)' : 'var(--border)',
|
||||
color: extractPending ? '#fff' : 'var(--text-secondary)',
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!generatingDesc && !descPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||
if (!extracting && !extractPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!descPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||
if (!extractPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||
}}
|
||||
aria-label={aiDescription ? 'Regenerate description' : 'Generate description'}
|
||||
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
|
||||
aria-label="Extract text with AI"
|
||||
title="Extract with AI (skips OCR)"
|
||||
>
|
||||
{generatingDesc || descPending ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
||||
{extractPending ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
||||
</button>
|
||||
</div>
|
||||
{/* Editable textarea */}
|
||||
<textarea
|
||||
value={editedDescription}
|
||||
onChange={(e) => setEditedDescription(e.target.value)}
|
||||
placeholder="No description yet…"
|
||||
className="text-xs rounded-lg p-2 w-full resize-y outline-none"
|
||||
style={{
|
||||
backgroundColor: 'var(--background)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--text-primary)',
|
||||
minHeight: '3.5rem',
|
||||
maxHeight: '8rem',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
{editedDescription !== (aiDescription ?? '') && (
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={async () => {
|
||||
setSavingDesc(true)
|
||||
try {
|
||||
await fetch('/api/ai-tagging/fields', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ itemKey, aiDescription: editedDescription }),
|
||||
})
|
||||
setAiDescription(editedDescription)
|
||||
} finally {
|
||||
setSavingDesc(false)
|
||||
onClick={() => callExtract('tesseract')}
|
||||
disabled={extracting || extractPending}
|
||||
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0"
|
||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!extracting && !extractPending) {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||||
}
|
||||
}}
|
||||
disabled={savingDesc}
|
||||
className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||
onMouseLeave={(e) => {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||
}}
|
||||
>
|
||||
{savingDesc ? '⟳ Saving…' : 'Save'}
|
||||
{extracting ? '⟳ Scanning…' : extractedText ? '🔍 Re-scan with OCR' : '🔍 Scan with OCR'}
|
||||
</button>
|
||||
)}
|
||||
{descError && <span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={ocrLanguageInput}
|
||||
onChange={(e) => setOcrLanguageInput(e.target.value)}
|
||||
placeholder={defaultOcrLanguages}
|
||||
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
||||
style={{
|
||||
backgroundColor: 'var(--background)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--text-primary)',
|
||||
width: 120,
|
||||
}}
|
||||
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Text extraction section — only for images */}
|
||||
{isImage && (
|
||||
<div className="flex flex-col gap-2" style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
||||
{/* Heading row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||||
Text Extraction
|
||||
</p>
|
||||
{/* AI button — forces LLM, no OCR */}
|
||||
<button
|
||||
onClick={() => callExtract('llm')}
|
||||
disabled={extracting || extractPending}
|
||||
className={`${smallBtn} disabled:opacity-50`}
|
||||
style={{
|
||||
backgroundColor: extractPending ? 'var(--accent)' : 'var(--border)',
|
||||
color: extractPending ? '#fff' : 'var(--text-secondary)',
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!extracting && !extractPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!extractPending) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||
}}
|
||||
aria-label="Extract text with AI"
|
||||
title="Extract with AI (skips OCR)"
|
||||
>
|
||||
{extractPending ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
||||
</button>
|
||||
</div>
|
||||
{extractError && <p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>}
|
||||
|
||||
{/* OCR button row */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => callExtract('tesseract')}
|
||||
disabled={extracting || extractPending}
|
||||
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: 'var(--border)',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!extracting && !extractPending) {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||
}}
|
||||
>
|
||||
{extracting ? '⟳ Scanning…' : extractedText ? '🔍 Re-scan with OCR' : '🔍 Scan with OCR'}
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={ocrLanguageInput}
|
||||
onChange={(e) => setOcrLanguageInput(e.target.value)}
|
||||
placeholder={defaultOcrLanguages}
|
||||
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
||||
style={{
|
||||
backgroundColor: 'var(--background)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--text-primary)',
|
||||
width: 120,
|
||||
}}
|
||||
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{extractError && (
|
||||
<p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>
|
||||
)}
|
||||
|
||||
{extractedText && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
Extracted Text
|
||||
</p>
|
||||
<textarea
|
||||
value={editedExtractedText}
|
||||
onChange={(e) => setEditedExtractedText(e.target.value)}
|
||||
className="text-xs whitespace-pre-wrap rounded-lg p-2 w-full resize-y outline-none"
|
||||
style={{
|
||||
backgroundColor: 'var(--background)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
minHeight: '4rem',
|
||||
maxHeight: '10rem',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
{editedExtractedText !== extractedText && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
setSavingText(true)
|
||||
try {
|
||||
await fetch('/api/ai-tagging/fields', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ itemKey, extractedText: editedExtractedText }),
|
||||
})
|
||||
setExtractedText(editedExtractedText)
|
||||
} finally {
|
||||
setSavingText(false)
|
||||
}
|
||||
}}
|
||||
disabled={savingText}
|
||||
className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||
>
|
||||
{savingText ? '⟳ Saving…' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{translatedText && (
|
||||
<div>
|
||||
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
Translation
|
||||
</p>
|
||||
<pre
|
||||
className="text-xs whitespace-pre-wrap rounded-lg p-2 max-h-40 overflow-y-auto"
|
||||
style={{ backgroundColor: 'var(--background)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||
>
|
||||
{translatedText}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
value={sourceLanguage}
|
||||
onChange={(e) => setSourceLanguage(e.target.value)}
|
||||
placeholder="Source lang…"
|
||||
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
||||
style={{
|
||||
backgroundColor: 'var(--background)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--text-primary)',
|
||||
width: 100,
|
||||
}}
|
||||
/>
|
||||
{extractedText && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
Extracted Text
|
||||
</p>
|
||||
<textarea
|
||||
value={editedExtractedText}
|
||||
onChange={(e) => setEditedExtractedText(e.target.value)}
|
||||
className="text-xs whitespace-pre-wrap rounded-lg p-2 w-full resize-y outline-none"
|
||||
style={{
|
||||
backgroundColor: 'var(--background)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
minHeight: '4rem',
|
||||
maxHeight: '10rem',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
{editedExtractedText !== extractedText && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
setRetranslating(true)
|
||||
setTranslatePending(false)
|
||||
setSavingText(true)
|
||||
try {
|
||||
const res = await fetch('/api/ai-tagging/translate', {
|
||||
method: 'POST',
|
||||
await fetch('/api/ai-tagging/fields', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
|
||||
body: JSON.stringify({ itemKey, extractedText: editedExtractedText }),
|
||||
})
|
||||
if (res.status === 202) {
|
||||
setTranslatePending(true)
|
||||
startPolling(extractedText, translatedText, aiDescription)
|
||||
return
|
||||
}
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error((data as { error?: string }).error ?? 'Failed to translate')
|
||||
}
|
||||
const result = await res.json()
|
||||
setTranslatedText(result.translatedText || null)
|
||||
} catch {
|
||||
// ignore
|
||||
setExtractedText(editedExtractedText)
|
||||
} finally {
|
||||
setRetranslating(false)
|
||||
}
|
||||
}}
|
||||
disabled={retranslating || translatePending}
|
||||
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: translatePending ? 'var(--accent)' : 'var(--border)',
|
||||
color: translatePending ? '#fff' : 'var(--text-secondary)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!retranslating && !translatePending) {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!translatePending) {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||
setSavingText(false)
|
||||
}
|
||||
}}
|
||||
disabled={savingText}
|
||||
className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||
>
|
||||
{retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
|
||||
{savingText ? '⟳ Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 />
|
||||
{translatedText && (
|
||||
<div>
|
||||
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||
Translation
|
||||
</p>
|
||||
<pre
|
||||
className="text-xs whitespace-pre-wrap rounded-lg p-2 max-h-40 overflow-y-auto"
|
||||
style={{ backgroundColor: 'var(--background)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||
>
|
||||
{translatedText}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
value={sourceLanguage}
|
||||
onChange={(e) => setSourceLanguage(e.target.value)}
|
||||
placeholder="Source lang…"
|
||||
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
||||
style={{
|
||||
backgroundColor: 'var(--background)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--text-primary)',
|
||||
width: 100,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setRetranslating(true)
|
||||
setTranslatePending(false)
|
||||
try {
|
||||
const res = await fetch('/api/ai-tagging/translate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
|
||||
})
|
||||
if (res.status === 202) {
|
||||
setTranslatePending(true)
|
||||
startPolling(extractedText, translatedText, aiDescription)
|
||||
return
|
||||
}
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error((data as { error?: string }).error ?? 'Failed to translate')
|
||||
}
|
||||
const result = await res.json()
|
||||
setTranslatedText(result.translatedText || null)
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setRetranslating(false)
|
||||
}
|
||||
}}
|
||||
disabled={retranslating || translatePending}
|
||||
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: translatePending ? 'var(--accent)' : 'var(--border)',
|
||||
color: translatePending ? '#fff' : 'var(--text-secondary)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!retranslating && !translatePending) {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!translatePending) {
|
||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MediaTagPanel>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import TagSelector from '@/components/tags/TagSelector'
|
||||
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
||||
import { useUserSettings } from '@/hooks/useUserSettings'
|
||||
|
||||
interface Props {
|
||||
@@ -28,9 +28,6 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
||||
const [showTagsLocal, setShowTagsLocal] = useState(false)
|
||||
const showTags = showTagsProp ?? showTagsLocal
|
||||
const setShowTags = onShowTagsChange ?? setShowTagsLocal
|
||||
const [aiTagging, setAiTagging] = useState(false)
|
||||
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
||||
const [tagRefreshKey, setTagRefreshKey] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
@@ -50,22 +47,6 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
|
||||
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'
|
||||
|
||||
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 */}
|
||||
{showTags && (
|
||||
<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={() => 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>
|
||||
<MediaTagPanel
|
||||
itemKey={itemKey!}
|
||||
onHide={() => setShowTags(false)}
|
||||
onClose={onClose}
|
||||
onTagsChanged={onTagsChanged}
|
||||
onAiTag={onAiTag}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Movie } from '@/types'
|
||||
import TagSelector from '@/components/tags/TagSelector'
|
||||
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
||||
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
|
||||
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 ── */}
|
||||
{showTagPanel && (
|
||||
<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={() => 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!}
|
||||
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
|
||||
refreshKey={tagRefreshKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MediaTagPanel
|
||||
itemKey={movie.item_key!}
|
||||
onHide={() => setShowTagPanel(false)}
|
||||
onClose={onClose}
|
||||
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
|
||||
/>
|
||||
)}
|
||||
</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 VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
|
||||
import MediaTagPanel from '@/components/tags/MediaTagPanel'
|
||||
import TagSelector from '@/components/tags/TagSelector'
|
||||
import AssignedTagBadges from '@/components/tags/AssignedTagBadges'
|
||||
import EpisodeCard from './EpisodeCard'
|
||||
@@ -397,6 +398,28 @@ export default function TvView({ libraryId }: Props) {
|
||||
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
|
||||
|
||||
if (playingEpisode && playingEpisodeIndex !== null) {
|
||||
@@ -1041,59 +1064,20 @@ export default function TvView({ libraryId }: Props) {
|
||||
|
||||
{/* Right tag panel */}
|
||||
{showTagPanel && (
|
||||
<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()}
|
||||
>
|
||||
<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={() => {
|
||||
setTagRefreshKey((k) => k + 1)
|
||||
setFilterRefreshKey((k) => k + 1)
|
||||
fetchAssignments()
|
||||
fetchSeriesEpisodeTags()
|
||||
}}
|
||||
refreshKey={tagRefreshKey}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<MediaTagPanel
|
||||
itemKey={tagPanelItemKey ?? ''}
|
||||
onHide={() => setShowTagPanel(false)}
|
||||
onClose={goToSeries}
|
||||
onTagsChanged={() => {
|
||||
setTagRefreshKey((k) => k + 1)
|
||||
setFilterRefreshKey((k) => k + 1)
|
||||
fetchAssignments()
|
||||
fetchSeriesEpisodeTags()
|
||||
}}
|
||||
externalRefreshKey={tagRefreshKey}
|
||||
disabled={tagPanelDisabled}
|
||||
disabledMessage="Seasons cannot be tagged. Select an episode to tag it."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user