diff --git a/src/components/games/GameDetailModal.tsx b/src/components/games/GameDetailModal.tsx index b9a8508..1c582ae 100644 --- a/src/components/games/GameDetailModal.tsx +++ b/src/components/games/GameDetailModal.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState, useCallback } from 'react' import type { Game, GameFile, GamePlatform } from '@/types' import TagSelector from '@/components/tags/TagSelector' +import AssignedTagBadges from '@/components/tags/AssignedTagBadges' // Import SVG icons import WindowsIcon from '@/app/icons/windows.svg' @@ -46,6 +47,9 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange const [renameName, setRenameName] = useState('') const [renameError, setRenameError] = useState(null) const [renameSaving, setRenameSaving] = useState(false) + const [showTagPanel, setShowTagPanel] = useState(false) + const [tagRefreshKey, setTagRefreshKey] = useState(0) + const [aiDescription, setAiDescription] = useState(null) // Screenshots state const [screenshots, setScreenshots] = useState>([]) @@ -54,6 +58,8 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange const [deletingScreenshot, setDeletingScreenshot] = useState(null) const [uploadingCount, setUploadingCount] = useState(0) + const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0' + const fetchScreenshots = useCallback(() => { setScreenshotsLoading(true) fetch(`/api/game-screenshots?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`) @@ -65,6 +71,14 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange useEffect(() => { fetchScreenshots() }, [fetchScreenshots]) + useEffect(() => { + if (!game.item_key) return + fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(game.item_key)}`) + .then((r) => r.json()) + .then((d: { aiDescription: string | null }) => setAiDescription(d.aiDescription ?? null)) + .catch(() => {}) + }, [game.item_key]) + const handleScreenshotUpload = async (e: React.ChangeEvent) => { const files = Array.from(e.target.files ?? []) if (files.length === 0) return @@ -111,6 +125,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange if (confirming) { setConfirming(false); return } if (renaming) { setRenaming(false); return } if (editingImages) { setEditingImages(false); return } + if (showTagPanel) { setShowTagPanel(false); return } onClose() } } @@ -120,7 +135,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange document.removeEventListener('keydown', handleKey) document.body.style.overflow = '' } - }, [onClose, menuOpen, editingImages, confirming, renaming, lightboxIndex, screenshots.length]) + }, [onClose, menuOpen, editingImages, confirming, renaming, showTagPanel, lightboxIndex, screenshots.length]) // Close menu on outside click useEffect(() => { @@ -153,306 +168,386 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange return (
-
- {editingImages ? ( - setEditingImages(false)} - onUploaded={onCoverUploaded} - /> - ) : ( - <> - {/* Close button */} + {/* Outer flex — row on md+, col on mobile when panel open */} +
+ + {/* ── Left pane — relative container for floating controls ── */} +
onClose()}> + {/* Scrollable card area */} +
+
e.stopPropagation()} + > + {editingImages ? ( + setEditingImages(false)} + onUploaded={onCoverUploaded} + /> + ) : ( + <> + + {/* Hero image */} +
+ {heroImage ? ( + // eslint-disable-next-line @next/next/no-img-element + {`${game.title} + ) : ( +
🎮
+ )} +
+ + {/* Info */} +
+ {/* Title row with kebab menu */} +
+

+ {game.title} +

+ + {/* Kebab menu */} +
+ + {menuOpen && ( +
+ + + {onDeleted && ( + + )} +
+ )} +
+
+ + {/* AI description (read-only) */} + {aiDescription && ( +

+ {aiDescription} +

+ )} + + {/* Rename inline input */} + {renaming && ( +
+
+ setRenameName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + const trimmed = renameName.trim() + if (!trimmed) return + setRenameSaving(true) + setRenameError(null) + fetch('/api/rename', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ libraryId, oldPath: decodeURIComponent(game.id), newName: trimmed, itemType: 'game' }), + }) + .then(async (res) => { + if (res.status === 409) { setRenameError((await res.json()).error); return } + if (!res.ok) throw new Error() + setRenaming(false) + onCoverUploaded?.() // triggers refetch + }) + .catch(() => setRenameError('Rename failed')) + .finally(() => setRenameSaving(false)) + } + if (e.key === 'Escape') setRenaming(false) + }} + className="flex-1 px-3 py-1.5 rounded-lg text-sm min-w-0" + style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }} + autoFocus + /> + + +
+ {renameError &&

{renameError}

} +
+ )} + + {/* Delete confirmation banner */} + {confirming && ( +
+

+ Permanently delete this game and all its files? +

+ + +
+ )} + + {/* Assigned tags (read-only) above download */} + {game.item_key && ( +
+ +
+ )} + + + + {/* Screenshots */} +
+

+ Screenshots +

+
+ {screenshotsLoading && screenshots.length === 0 ? ( +
+ ) : ( + <> + {screenshots.map((shot, idx) => ( +
setLightboxIndex(idx)} + > + {/* eslint-disable-next-line @next/next/no-img-element */} + {`Screenshot + {deletingScreenshot !== shot.filename && ( + + )} + {deletingScreenshot === shot.filename && ( +
+ Deleting… +
+ )} +
+ ))} + {Array.from({ length: uploadingCount }).map((_, i) => ( +
+ Uploading… +
+ ))} + + + )} +
+ +
+
+ + )} +
+
+ + {/* Floating controls — tag + close */} +
e.stopPropagation()}> + {game.item_key && !showTagPanel && ( + + )} +
+
- {/* Hero image */} -
- {heroImage ? ( - // eslint-disable-next-line @next/next/no-img-element - {`${game.title} - ) : ( -
🎮
- )} + {/* ── Tag panel — bottom half on mobile, right sidebar on desktop ── */} + {showTagPanel && ( +
e.stopPropagation()} + > + {/* Panel header — ‹ hide | ✕ close */} +
+ +
- {/* Info */} -
- {/* Title row with kebab menu */} -
-

- {game.title} -

- - {/* Kebab menu */} -
- - {menuOpen && ( -
- - - {onDeleted && ( - - )} -
- )} -
-
- - {/* Rename inline input */} - {renaming && ( -
-
- setRenameName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - const trimmed = renameName.trim() - if (!trimmed) return - setRenameSaving(true) - setRenameError(null) - fetch('/api/rename', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ libraryId, oldPath: decodeURIComponent(game.id), newName: trimmed, itemType: 'game' }), - }) - .then(async (res) => { - if (res.status === 409) { setRenameError((await res.json()).error); return } - if (!res.ok) throw new Error() - setRenaming(false) - onCoverUploaded?.() // triggers refetch - }) - .catch(() => setRenameError('Rename failed')) - .finally(() => setRenameSaving(false)) - } - if (e.key === 'Escape') setRenaming(false) - }} - className="flex-1 px-3 py-1.5 rounded-lg text-sm min-w-0" - style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }} - autoFocus - /> - - -
- {renameError &&

{renameError}

} -
- )} - - {/* Delete confirmation banner */} - {confirming && ( -
-

- Permanently delete this game and all its files? -

- - -
- )} - - - - {/* Screenshots */} -
-

- Screenshots -

-
- {screenshotsLoading && screenshots.length === 0 ? ( -
- ) : ( - <> - {screenshots.map((shot, idx) => ( -
setLightboxIndex(idx)} - > - {/* eslint-disable-next-line @next/next/no-img-element */} - {`Screenshot - {deletingScreenshot !== shot.filename && ( - - )} - {deletingScreenshot === shot.filename && ( -
- Deleting… -
- )} -
- ))} - {Array.from({ length: uploadingCount }).map((_, i) => ( -
- Uploading… -
- ))} - - - )} -
- -
- - {/* Tags */} -
-

- Tags -

- -
+ {/* Tags */} +
+

+ Tags +

+ { setTagRefreshKey((k) => k + 1); onTagsChanged?.() }} + refreshKey={tagRefreshKey} + />
- +
)}
- {/* Lightbox */} + {/* Screenshot lightbox (z-60, sits above the modal) */} {lightboxIndex !== null && (
{primary.filename} - + ) } diff --git a/src/components/mixed/ImageLightbox.tsx b/src/components/mixed/ImageLightbox.tsx index 3d045fa..12316b2 100644 --- a/src/components/mixed/ImageLightbox.tsx +++ b/src/components/mixed/ImageLightbox.tsx @@ -325,17 +325,19 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, item 🏷 )} - + {!showTags && ( + + )}
{/* Text display button — bottom-right, hidden when panel open */} diff --git a/src/components/mixed/VideoPlayerModal.tsx b/src/components/mixed/VideoPlayerModal.tsx index 5caea0e..2769901 100644 --- a/src/components/mixed/VideoPlayerModal.tsx +++ b/src/components/mixed/VideoPlayerModal.tsx @@ -100,17 +100,19 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, i 🏷 )} - + {!showTags && ( + + )}
diff --git a/src/components/movies/MovieDetailModal.tsx b/src/components/movies/MovieDetailModal.tsx index 4dd3082..46b2233 100644 --- a/src/components/movies/MovieDetailModal.tsx +++ b/src/components/movies/MovieDetailModal.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react' import type { Movie } from '@/types' import TagSelector from '@/components/tags/TagSelector' +import AssignedTagBadges from '@/components/tags/AssignedTagBadges' import VideoPlayerModal from '@/components/mixed/VideoPlayerModal' interface Props { @@ -32,6 +33,10 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on const [renameName, setRenameName] = useState('') const [renameError, setRenameError] = useState(null) const [renameSaving, setRenameSaving] = useState(false) + const [showTagPanel, setShowTagPanel] = useState(false) + const [tagRefreshKey, setTagRefreshKey] = useState(0) + + const smallBtn = 'w-7 h-7 rounded-full flex items-center justify-center transition-colors flex-shrink-0' useEffect(() => { const handleKey = (e: KeyboardEvent) => { @@ -41,6 +46,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on if (warnRefresh) { setWarnRefresh(false); return } if (editing) { setEditing(false); return } if (renaming) { setRenaming(false); return } + if (showTagPanel) { setShowTagPanel(false); return } onClose() } } @@ -50,7 +56,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on document.removeEventListener('keydown', handleKey) document.body.style.overflow = '' } - }, [onClose, menuOpen, confirming, editing, warnRefresh, renaming]) + }, [onClose, menuOpen, confirming, editing, warnRefresh, renaming, showTagPanel]) // Close menu on outside click useEffect(() => { @@ -132,7 +138,6 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on const handleStartRename = () => { setMenuOpen(false) - // movie.id is the encoded folder name setRenameName(decodeURIComponent(movie.id)) setRenameError(null) setRenaming(true) @@ -187,339 +192,423 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on return (
-
- {/* Close button */} - + {/* Outer flex — row on md+, col on mobile when panel open */} +
- {/* Prev / Next buttons on the detail card */} - {onPrev && ( - - )} - {onNext && ( - - )} - {/* Hero image */} -
- {heroUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - {movie.title} - ) : ( -
🎬
- )} -
+ {/* Hero image */} +
+ {heroUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {movie.title} + ) : ( +
🎬
+ )} +
- {/* Info */} -
- {/* Title row with kebab menu */} -
-

- {movie.title} -

- {movie.year && ( - - {movie.year} - - )} - {/* Kebab menu */} -
- - {menuOpen && ( + {/* Info */} +
+ {/* Title row with kebab menu */} +
+

+ {movie.title} +

+ {movie.year && ( + + {movie.year} + + )} + {/* Kebab menu */} +
+ + {menuOpen && ( +
+ + + + +
+ )} +
+
+ + {/* Rename inline input */} + {renaming && ( +
+
+ setRenameName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setRenaming(false) }} + className="flex-1 px-3 py-1.5 rounded-lg text-sm min-w-0" + style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }} + autoFocus + /> + + +
+ {renameError &&

{renameError}

} +
+ )} + + {editing ? ( +
+
+ + setEditForm((f) => ({ ...f, title: e.target.value }))} + className="w-full px-3 py-1.5 rounded-lg text-sm" + style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }} + autoFocus + /> +
+
+ + setEditForm((f) => ({ ...f, year: e.target.value }))} + className="w-full px-3 py-1.5 rounded-lg text-sm" + style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }} + /> +
+
+ +