handle video tagging

This commit is contained in:
Garret Patti
2026-04-12 17:24:39 -04:00
parent ad9920a448
commit 6c769b457f
6 changed files with 178 additions and 52 deletions

View File

@@ -21,6 +21,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
)
const [aiTagging, setAiTagging] = useState(false)
const [aiTagError, setAiTagError] = useState<string | null>(null)
const [tagRefreshKey, setTagRefreshKey] = useState(0)
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
@@ -82,6 +83,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
setAiTagError(null)
try {
await onAiTag()
setTagRefreshKey((k) => k + 1)
onTagsChanged?.()
} catch (err) {
setAiTagError(err instanceof Error ? err.message : 'AI tagging failed')
@@ -165,7 +167,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} />
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} />
</div>
</div>
) : (

View File

@@ -378,6 +378,19 @@ export default function MixedView({ libraryId, initialPath }: Props) {
onClose={() => setModal(null)}
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
onNext={modal.mediaIndex < mediaEntries.length - 1 ? () => navigateModal(1) : undefined}
onAiTag={modal.itemKey ? async () => {
const res = await fetch('/api/ai-tagging', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey: modal.itemKey }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'AI tagging failed')
}
fetchAssignments()
setFilterRefreshKey((k) => k + 1)
} : undefined}
/>
)}
{modal?.type === 'image' && (

View File

@@ -12,10 +12,11 @@ interface Props {
onNext?: () => void
itemKey?: string
onTagsChanged?: () => void
onAiTag?: () => Promise<void>
context?: 'mixed' | 'movies' | 'tv'
}
export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, context = 'mixed' }: Props) {
export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, onAiTag, context = 'mixed' }: Props) {
const settings = useUserSettings()
const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay
const loop = context === 'mixed' ? settings.mixedLoop : context === 'movies' ? settings.moviesLoop : settings.tvLoop
@@ -24,6 +25,9 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
const [showTags, setShowTags] = useState(
() => !!itemKey && typeof window !== 'undefined' && window.innerWidth >= 1280
)
const [aiTagging, setAiTagging] = useState(false)
const [aiTagError, setAiTagError] = useState<string | null>(null)
const [tagRefreshKey, setTagRefreshKey] = useState(0)
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
@@ -76,6 +80,43 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
🏷
</button>
)}
{onAiTag && (
<button
onClick={async (e) => {
e.stopPropagation()
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)
}
}}
disabled={aiTagging}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors disabled:opacity-50"
style={{
backgroundColor: aiTagError ? '#7f1d1d' : 'var(--surface)',
color: aiTagError ? '#fca5a5' : 'var(--text-primary)',
}}
onMouseEnter={(e) => {
if (!aiTagging && !aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface-hover)'
}}
onMouseLeave={(e) => {
if (!aiTagError) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--surface)'
}}
aria-label="AI Tag this video"
title={aiTagError ?? (aiTagging ? 'Tagging…' : 'AI Tag')}
>
{aiTagging ? (
<span className="animate-spin" style={{ display: 'inline-block' }}></span>
) : '✨'}
</button>
)}
<button
onClick={onClose}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm flex-shrink-0 transition-colors"
@@ -134,7 +175,7 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} />
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} refreshKey={tagRefreshKey} />
</div>
</div>
) : (

View File

@@ -7,6 +7,7 @@ import TagBadge from './TagBadge'
interface Props {
itemKey: string
onTagsChanged?: () => void
refreshKey?: number
}
interface AllTags {
@@ -14,7 +15,7 @@ interface AllTags {
tags: Tag[]
}
export default function TagSelector({ itemKey, onTagsChanged }: Props) {
export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Props) {
const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({
tags: [],
categories: [],
@@ -58,6 +59,12 @@ export default function TagSelector({ itemKey, onTagsChanged }: Props) {
Promise.all([fetchAssigned(), fetchAll()]).finally(() => setLoading(false))
}, [fetchAssigned, fetchAll])
useEffect(() => {
if (refreshKey !== undefined && refreshKey > 0) {
fetchAssigned()
}
}, [refreshKey, fetchAssigned])
const isAssigned = (tagId: string) => assigned.tags.some((t) => t.id === tagId)
const toggleTag = async (tag: Tag) => {