From 625e256944c6fd30ac8d3f9c7051a67e801e0b26 Mon Sep 17 00:00:00 2001
From: Garret Patti <42485635+garretpatti@users.noreply.github.com>
Date: Sat, 18 Apr 2026 11:10:26 -0400
Subject: [PATCH] reduce repeated tag selector code
---
src/components/games/GameDetailModal.tsx | 51 +-
src/components/mixed/ImageLightbox.tsx | 541 +++++++++------------
src/components/mixed/VideoPlayerModal.tsx | 94 +---
src/components/movies/MovieDetailModal.tsx | 51 +-
src/components/tags/MediaTagPanel.tsx | 135 +++++
src/components/tv/TvView.tsx | 90 ++--
6 files changed, 418 insertions(+), 544 deletions(-)
create mode 100644 src/components/tags/MediaTagPanel.tsx
diff --git a/src/components/games/GameDetailModal.tsx b/src/components/games/GameDetailModal.tsx
index 0dbafd7..5221876 100644
--- a/src/components/games/GameDetailModal.tsx
+++ b/src/components/games/GameDetailModal.tsx
@@ -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 && (
-
e.stopPropagation()}
- >
- {/* Panel header — ‹ hide | ✕ close */}
-
-
-
-
-
- {/* Tags */}
-
-
- Tags
-
-
{ setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
- refreshKey={tagRefreshKey}
- />
-
-
+ setShowTagPanel(false)}
+ onClose={onClose}
+ onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
+ />
)}
diff --git a/src/components/mixed/ImageLightbox.tsx b/src/components/mixed/ImageLightbox.tsx
index 12316b2..5a1197a 100644
--- a/src/components/mixed/ImageLightbox.tsx
+++ b/src/components/mixed/ImageLightbox.tsx
@@ -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(null)
- const [tagRefreshKey, setTagRefreshKey] = useState(0)
-
// Text extraction state
const [extractedText, setExtractedText] = useState(null)
const [translatedText, setTranslatedText] = useState(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 && (
- e.stopPropagation()}
+
setShowTags(false)}
+ onClose={onClose}
+ onTagsChanged={onTagsChanged}
+ onAiTag={onAiTag}
>
- {/* Panel header — ‹ hide | ✨ AI tag ✕ close */}
-
-
-
+ {/* Description section */}
+
+
+
+ Description
+
+
- {/* Scrollable panel content */}
-
-
- {/* Description section */}
-
- {/* Heading row */}
-
+ {/* Text extraction section — only for images */}
+ {isImage && (
+
+
- Description
+ Text Extraction
- {/* Editable textarea */}
-
- {/* Text extraction section — only for images */}
- {isImage && (
-
- {/* Heading row */}
-
-
- Text Extraction
-
- {/* AI button — forces LLM, no OCR */}
-
-
+ {extractError &&
{extractError}
}
- {/* OCR button row */}
-
-
- 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."
- />
-
-
- {extractError && (
-
{extractError}
- )}
-
- {extractedText && (
-
-
-
- {translatedText && (
-
-
- Translation
-
-
- {translatedText}
-
-
- )}
-
-
-
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 && (
+
- )}
-
- )}
- {/* Tags section */}
-
-
-
- Tags
-
- {onAiTag && (
-
- )}
-
- {aiTagError &&
{aiTagError}
}
-
+ {translatedText && (
+
+
+ Translation
+
+
+ {translatedText}
+
+
+ )}
+
+
+ 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,
+ }}
+ />
+
+
+
+ )}
-
-
+ )}
+
)}
diff --git a/src/components/mixed/VideoPlayerModal.tsx b/src/components/mixed/VideoPlayerModal.tsx
index 2769901..9309943 100644
--- a/src/components/mixed/VideoPlayerModal.tsx
+++ b/src/components/mixed/VideoPlayerModal.tsx
@@ -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
(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 && (
- e.stopPropagation()}
- >
- {/* Panel header — ‹ hide | ✕ close */}
-
-
-
-
-
-
-
- {/* Tags */}
-
-
-
- Tags
-
- {onAiTag && (
-
- )}
-
- {aiTagError &&
{aiTagError}
}
-
-
-
+ setShowTags(false)}
+ onClose={onClose}
+ onTagsChanged={onTagsChanged}
+ onAiTag={onAiTag}
+ />
)}
diff --git a/src/components/movies/MovieDetailModal.tsx b/src/components/movies/MovieDetailModal.tsx
index 7879679..e96ef94 100644
--- a/src/components/movies/MovieDetailModal.tsx
+++ b/src/components/movies/MovieDetailModal.tsx
@@ -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 && (
- e.stopPropagation()}
- >
- {/* Panel header — ‹ hide | ✕ close */}
-
-
-
-
-
- {/* Tags */}
-
-
- Tags
-
-
{ setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
- refreshKey={tagRefreshKey}
- />
-
-
+ setShowTagPanel(false)}
+ onClose={onClose}
+ onTagsChanged={() => { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }}
+ />
)}
diff --git a/src/components/tags/MediaTagPanel.tsx b/src/components/tags/MediaTagPanel.tsx
new file mode 100644
index 0000000..6111965
--- /dev/null
+++ b/src/components/tags/MediaTagPanel.tsx
@@ -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
+ 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(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 (
+ e.stopPropagation()}
+ >
+ {/* Panel header — ‹ hide | ✕ close */}
+
+
+
+
+
+ {/* Scrollable content */}
+
+ {children}
+
+ {disabled || !itemKey ? (
+ disabledMessage ? (
+
+ {disabledMessage}
+
+ ) : null
+ ) : (
+ <>
+ {/* Tags section heading + optional AI button */}
+
+
+ Tags
+
+ {onAiTag && (
+
+ )}
+
+ {aiTagError &&
{aiTagError}
}
+
+ >
+ )}
+
+
+ )
+}
diff --git a/src/components/tv/TvView.tsx b/src/components/tv/TvView.tsx
index 72e3f11..b3f8f47 100644
--- a/src/components/tv/TvView.tsx
+++ b/src/components/tv/TvView.tsx
@@ -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 && (
- e.stopPropagation()}
- >
-
-
-
-
-
- {tagPanelDisabled ? (
-
- Seasons cannot be tagged. Select an episode to tag it.
-
- ) : tagPanelItemKey ? (
- <>
-
- Tags
-
-
{
- setTagRefreshKey((k) => k + 1)
- setFilterRefreshKey((k) => k + 1)
- fetchAssignments()
- fetchSeriesEpisodeTags()
- }}
- refreshKey={tagRefreshKey}
- />
- >
- ) : null}
-
-
+ 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."
+ />
)}