Add screenshots to game detail modal
- New /api/game-screenshots route handles GET (list), POST (upload), and DELETE (remove single file)
- Screenshots stored in a screenshots/ subfolder inside each game directory
- Images converted to JPEG via Sharp on upload, named shot-{timestamp}.jpg
- Modal shows a horizontal scrollable strip of 16:9 thumbnail tiles
- Hover a tile to reveal a delete button; uploading placeholders appear per in-flight upload
- Dashed + tile triggers multi-file picker for batch uploads
- Click any thumbnail to open a full-screen lightbox with prev/next arrows, counter, and keyboard nav (←/→/Esc)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ interface Props {
|
||||
export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged, onCoverUploaded, onDeleted }: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const screenshotInputRef = useRef<HTMLInputElement>(null)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [editingImages, setEditingImages] = useState(false)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
@@ -36,8 +37,65 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
||||
const [renameError, setRenameError] = useState<string | null>(null)
|
||||
const [renameSaving, setRenameSaving] = useState(false)
|
||||
|
||||
// Screenshots state
|
||||
const [screenshots, setScreenshots] = useState<Array<{ filename: string; url: string; thumbnailUrl: string }>>([])
|
||||
const [screenshotsLoading, setScreenshotsLoading] = useState(false)
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
|
||||
const [deletingScreenshot, setDeletingScreenshot] = useState<string | null>(null)
|
||||
const [uploadingCount, setUploadingCount] = useState(0)
|
||||
|
||||
const fetchScreenshots = useCallback(() => {
|
||||
setScreenshotsLoading(true)
|
||||
fetch(`/api/game-screenshots?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => setScreenshots(data.screenshots ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setScreenshotsLoading(false))
|
||||
}, [libraryId, game.id])
|
||||
|
||||
useEffect(() => { fetchScreenshots() }, [fetchScreenshots])
|
||||
|
||||
const handleScreenshotUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? [])
|
||||
if (files.length === 0) return
|
||||
e.target.value = ''
|
||||
for (const file of files) {
|
||||
setUploadingCount((n) => n + 1)
|
||||
const form = new FormData()
|
||||
form.append('screenshot', file)
|
||||
try {
|
||||
await fetch(
|
||||
`/api/game-screenshots?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`,
|
||||
{ method: 'POST', body: form }
|
||||
)
|
||||
} catch { /* ignore */ }
|
||||
finally { setUploadingCount((n) => n - 1) }
|
||||
}
|
||||
fetchScreenshots()
|
||||
}
|
||||
|
||||
const handleDeleteScreenshot = async (filename: string) => {
|
||||
setDeletingScreenshot(filename)
|
||||
try {
|
||||
await fetch(
|
||||
`/api/game-screenshots?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}&filename=${encodeURIComponent(filename)}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
} catch { /* ignore */ }
|
||||
finally {
|
||||
setDeletingScreenshot(null)
|
||||
fetchScreenshots()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (lightboxIndex !== null) {
|
||||
if (e.key === 'Escape') { setLightboxIndex(null); return }
|
||||
if (e.key === 'ArrowLeft') { setLightboxIndex((i) => (i! > 0 ? i! - 1 : i)); return }
|
||||
if (e.key === 'ArrowRight') { setLightboxIndex((i) => (i! < screenshots.length - 1 ? i! + 1 : i)); return }
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
if (menuOpen) { setMenuOpen(false); return }
|
||||
if (confirming) { setConfirming(false); return }
|
||||
@@ -52,7 +110,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
||||
document.removeEventListener('keydown', handleKey)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [onClose, menuOpen, editingImages, confirming, renaming])
|
||||
}, [onClose, menuOpen, editingImages, confirming, renaming, lightboxIndex, screenshots.length])
|
||||
|
||||
// Close menu on outside click
|
||||
useEffect(() => {
|
||||
@@ -298,6 +356,80 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
||||
|
||||
<DownloadButton gameFiles={game.gameFiles} clientPlatform={clientPlatform} downloadUrl={fileDownloadUrl} />
|
||||
|
||||
{/* Screenshots */}
|
||||
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
Screenshots
|
||||
</p>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'thin' }}>
|
||||
{screenshotsLoading && screenshots.length === 0 ? (
|
||||
<div className="flex-shrink-0 w-36 aspect-video rounded-lg animate-pulse" style={{ backgroundColor: 'var(--border)' }} />
|
||||
) : (
|
||||
<>
|
||||
{screenshots.map((shot, idx) => (
|
||||
<div
|
||||
key={shot.filename}
|
||||
className="group relative flex-shrink-0 w-36 aspect-video rounded-lg overflow-hidden cursor-pointer"
|
||||
style={{ backgroundColor: 'var(--border)' }}
|
||||
onClick={() => setLightboxIndex(idx)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={shot.thumbnailUrl} alt={`Screenshot ${idx + 1}`} className="w-full h-full object-cover" />
|
||||
{deletingScreenshot !== shot.filename && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteScreenshot(shot.filename) }}
|
||||
className="absolute top-1 right-1 w-5 h-5 rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}
|
||||
aria-label="Delete screenshot"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
{deletingScreenshot === shot.filename && (
|
||||
<div className="absolute inset-0 flex items-center justify-center" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||
<span className="text-xs text-white">Deleting…</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{Array.from({ length: uploadingCount }).map((_, i) => (
|
||||
<div
|
||||
key={`uploading-${i}`}
|
||||
className="flex-shrink-0 w-36 aspect-video rounded-lg flex items-center justify-center animate-pulse"
|
||||
style={{ backgroundColor: 'var(--border)' }}
|
||||
>
|
||||
<span className="text-xs" style={{ color: 'var(--text-secondary)' }}>Uploading…</span>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => screenshotInputRef.current?.click()}
|
||||
className="flex-shrink-0 w-36 aspect-video rounded-lg flex items-center justify-center border-2 border-dashed transition-colors"
|
||||
style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}
|
||||
onMouseEnter={(e) => {
|
||||
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--accent)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
|
||||
;(e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)'
|
||||
}}
|
||||
aria-label="Add screenshot"
|
||||
>
|
||||
<span className="text-xl">+</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={screenshotInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleScreenshotUpload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
@@ -309,6 +441,75 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lightbox */}
|
||||
{lightboxIndex !== null && (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.92)', zIndex: 60 }}
|
||||
onClick={() => setLightboxIndex(null)}
|
||||
>
|
||||
<div
|
||||
className="relative flex items-center justify-center w-full h-full p-8"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={screenshots[lightboxIndex].url}
|
||||
alt={`Screenshot ${lightboxIndex + 1}`}
|
||||
className="max-w-full max-h-full object-contain rounded-lg shadow-2xl"
|
||||
/>
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
onClick={() => setLightboxIndex(null)}
|
||||
className="absolute top-4 right-4 w-9 h-9 rounded-full flex items-center justify-center transition-colors"
|
||||
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: '#fff' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.3)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.15)')}
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
{/* Prev */}
|
||||
{lightboxIndex > 0 && (
|
||||
<button
|
||||
onClick={() => setLightboxIndex((i) => i! - 1)}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 w-9 h-9 rounded-full flex items-center justify-center transition-colors"
|
||||
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: '#fff' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.3)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.15)')}
|
||||
aria-label="Previous screenshot"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Next */}
|
||||
{lightboxIndex < screenshots.length - 1 && (
|
||||
<button
|
||||
onClick={() => setLightboxIndex((i) => i! + 1)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 w-9 h-9 rounded-full flex items-center justify-center transition-colors"
|
||||
style={{ backgroundColor: 'rgba(255,255,255,0.15)', color: '#fff' }}
|
||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.3)')}
|
||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.15)')}
|
||||
aria-label="Next screenshot"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Counter */}
|
||||
<div
|
||||
className="absolute bottom-4 left-1/2 -translate-x-1/2 text-xs px-3 py-1 rounded-full"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.5)', color: 'rgba(255,255,255,0.7)' }}
|
||||
>
|
||||
{lightboxIndex + 1} / {screenshots.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user