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"
|
itemKey={game.item_key!}
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
onHide={() => setShowTagPanel(false)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClose={onClose}
|
||||||
>
|
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
|
||||||
{/* 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>
|
|
||||||
)}
|
)}
|
||||||
</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,343 +349,270 @@ 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 */}
|
{/* Description section */}
|
||||||
<div className="flex items-center justify-between p-4 flex-shrink-0">
|
<div className="flex flex-col gap-1 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||||
<button
|
<div className="flex items-center justify-between mb-2">
|
||||||
onClick={() => setShowTags(false)}
|
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||||||
className={smallBtn}
|
Description
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
</p>
|
||||||
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
|
<button
|
||||||
onClick={onClose}
|
onClick={handleGenerateDescription}
|
||||||
className={smallBtn}
|
disabled={generatingDesc || descPending}
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
className={`${smallBtn} disabled:opacity-50`}
|
||||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
style={{
|
||||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
backgroundColor: descPending ? 'var(--accent)' : 'var(--border)',
|
||||||
aria-label="Close"
|
color: descPending ? '#fff' : 'var(--text-secondary)',
|
||||||
title="Close"
|
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>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable panel content */}
|
{/* Text extraction section — only for images */}
|
||||||
<div className="overflow-y-auto flex-1 min-h-0 flex flex-col gap-4 px-4 pb-4">
|
{isImage && (
|
||||||
|
<div className="flex flex-col gap-2 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||||
{/* Description section */}
|
<div className="flex items-center justify-between">
|
||||||
<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">
|
|
||||||
<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
|
Text Extraction
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleGenerateDescription}
|
onClick={() => callExtract('llm')}
|
||||||
disabled={generatingDesc || descPending}
|
disabled={extracting || extractPending}
|
||||||
className={`${smallBtn} disabled:opacity-50`}
|
className={`${smallBtn} disabled:opacity-50`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: descPending ? 'var(--accent)' : 'var(--border)',
|
backgroundColor: extractPending ? 'var(--accent)' : 'var(--border)',
|
||||||
color: descPending ? '#fff' : 'var(--text-secondary)',
|
color: extractPending ? '#fff' : 'var(--text-secondary)',
|
||||||
fontSize: '1rem',
|
fontSize: '1rem',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
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) => {
|
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'}
|
aria-label="Extract text with AI"
|
||||||
title={aiDescription ? 'Regenerate AI description' : 'Generate AI description'}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* Editable textarea */}
|
|
||||||
<textarea
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
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
|
<button
|
||||||
onClick={async () => {
|
onClick={() => callExtract('tesseract')}
|
||||||
setSavingDesc(true)
|
disabled={extracting || extractPending}
|
||||||
try {
|
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0"
|
||||||
await fetch('/api/ai-tagging/fields', {
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||||
method: 'PATCH',
|
onMouseEnter={(e) => {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
if (!extracting && !extractPending) {
|
||||||
body: JSON.stringify({ itemKey, aiDescription: editedDescription }),
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
||||||
})
|
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
||||||
setAiDescription(editedDescription)
|
|
||||||
} finally {
|
|
||||||
setSavingDesc(false)
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={savingDesc}
|
onMouseLeave={(e) => {
|
||||||
className="mt-1 text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-50"
|
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
||||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{savingDesc ? '⟳ Saving…' : 'Save'}
|
{extracting ? '⟳ Scanning…' : extractedText ? '🔍 Re-scan with OCR' : '🔍 Scan with OCR'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
<input
|
||||||
{descError && <span className="text-xs" style={{ color: '#f87171' }}>{descError}</span>}
|
type="text"
|
||||||
</div>
|
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 */}
|
{extractError && <p className="text-xs" style={{ color: '#f87171' }}>{extractError}</p>}
|
||||||
{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>
|
|
||||||
|
|
||||||
{/* OCR button row */}
|
{extractedText && (
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex flex-col gap-2">
|
||||||
<button
|
<div>
|
||||||
onClick={() => callExtract('tesseract')}
|
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
disabled={extracting || extractPending}
|
Extracted Text
|
||||||
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-50 self-start flex-shrink-0"
|
</p>
|
||||||
style={{
|
<textarea
|
||||||
backgroundColor: 'var(--border)',
|
value={editedExtractedText}
|
||||||
color: 'var(--text-secondary)',
|
onChange={(e) => setEditedExtractedText(e.target.value)}
|
||||||
}}
|
className="text-xs whitespace-pre-wrap rounded-lg p-2 w-full resize-y outline-none"
|
||||||
onMouseEnter={(e) => {
|
style={{
|
||||||
if (!extracting && !extractPending) {
|
backgroundColor: 'var(--background)',
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
color: 'var(--text-primary)',
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--background)'
|
border: '1px solid var(--border)',
|
||||||
}
|
minHeight: '4rem',
|
||||||
}}
|
maxHeight: '10rem',
|
||||||
onMouseLeave={(e) => {
|
fontFamily: 'inherit',
|
||||||
;(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
}}
|
||||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
/>
|
||||||
}}
|
{editedExtractedText !== extractedText && (
|
||||||
>
|
|
||||||
{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,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setRetranslating(true)
|
setSavingText(true)
|
||||||
setTranslatePending(false)
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/ai-tagging/translate', {
|
await fetch('/api/ai-tagging/fields', {
|
||||||
method: 'POST',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ itemKey, ...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }) }),
|
body: JSON.stringify({ itemKey, extractedText: editedExtractedText }),
|
||||||
})
|
})
|
||||||
if (res.status === 202) {
|
setExtractedText(editedExtractedText)
|
||||||
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 {
|
} finally {
|
||||||
setRetranslating(false)
|
setSavingText(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)'
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
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>
|
</button>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tags section */}
|
{translatedText && (
|
||||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem' }}>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<p className="text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
Translation
|
||||||
Tags
|
</p>
|
||||||
</p>
|
<pre
|
||||||
{onAiTag && (
|
className="text-xs whitespace-pre-wrap rounded-lg p-2 max-h-40 overflow-y-auto"
|
||||||
<button
|
style={{ backgroundColor: 'var(--background)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||||
onClick={handleAiTag}
|
>
|
||||||
disabled={aiTagging}
|
{translatedText}
|
||||||
className={`${smallBtn} disabled:opacity-50`}
|
</pre>
|
||||||
style={{
|
</div>
|
||||||
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--border)',
|
)}
|
||||||
color: aiTagError ? '#fca5a5' : 'var(--text-secondary)',
|
|
||||||
fontSize: '1rem',
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
}}
|
<input
|
||||||
onMouseEnter={(e) => {
|
type="text"
|
||||||
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'
|
value={sourceLanguage}
|
||||||
}}
|
onChange={(e) => setSourceLanguage(e.target.value)}
|
||||||
onMouseLeave={(e) => {
|
placeholder="Source lang…"
|
||||||
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'
|
className="text-xs px-2 py-0.5 rounded-full outline-none"
|
||||||
}}
|
style={{
|
||||||
aria-label="AI Tag this image"
|
backgroundColor: 'var(--background)',
|
||||||
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
|
border: '1px solid var(--border)',
|
||||||
>
|
color: 'var(--text-primary)',
|
||||||
{aiTagging ? <span className="animate-spin" style={{ display: 'inline-block' }}>⟳</span> : '✨'}
|
width: 100,
|
||||||
</button>
|
}}
|
||||||
)}
|
/>
|
||||||
</div>
|
<button
|
||||||
{aiTagError && <p className="text-xs mb-2" style={{ color: '#f87171' }}>{aiTagError}</p>}
|
onClick={async () => {
|
||||||
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} hideDescription />
|
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>
|
)}
|
||||||
</div>
|
</MediaTagPanel>
|
||||||
)}
|
)}
|
||||||
</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"
|
itemKey={movie.item_key!}
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
onHide={() => setShowTagPanel(false)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClose={onClose}
|
||||||
>
|
onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
|
||||||
{/* 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>
|
|
||||||
)}
|
)}
|
||||||
</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}
|
||||||
>
|
onTagsChanged={() => {
|
||||||
<div className="flex items-center justify-between p-4 flex-shrink-0">
|
setTagRefreshKey((k) => k + 1)
|
||||||
<button
|
setFilterRefreshKey((k) => k + 1)
|
||||||
onClick={() => setShowTagPanel(false)}
|
fetchAssignments()
|
||||||
className={smallBtn}
|
fetchSeriesEpisodeTags()
|
||||||
style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)', fontSize: '0.85rem' }}
|
}}
|
||||||
onMouseEnter={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--text-secondary)'}
|
externalRefreshKey={tagRefreshKey}
|
||||||
onMouseLeave={(e) => (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)'}
|
disabled={tagPanelDisabled}
|
||||||
aria-label="Hide panel"
|
disabledMessage="Seasons cannot be tagged. Select an episode to tag it."
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user