From 84c65c7964f4f896d3a23fd5393832e674ce4fcc Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Sun, 12 Apr 2026 10:18:38 -0400 Subject: [PATCH] Add screenshots to game detail modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/app/api/game-screenshots/route.ts | 177 ++++++++++++++++++++ src/components/games/GameDetailModal.tsx | 203 ++++++++++++++++++++++- 2 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 src/app/api/game-screenshots/route.ts diff --git a/src/app/api/game-screenshots/route.ts b/src/app/api/game-screenshots/route.ts new file mode 100644 index 0000000..b3d62f8 --- /dev/null +++ b/src/app/api/game-screenshots/route.ts @@ -0,0 +1,177 @@ +import path from 'path' +import fs from 'fs' +import sharp from 'sharp' +import { NextRequest, NextResponse } from 'next/server' +import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries' +import { requireAdmin, requireLibraryAccess } from '@/lib/auth' +import { fileApiUrl, thumbnailApiUrl } from '@/lib/media-utils' + +const SCREENSHOT_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']) +const MAX_SCREENSHOT_BYTES = 20 * 1024 * 1024 // 20 MB + +type GameDirResult = + | { gameDir: string; screenshotsDir: string; folderPath: string } + | { error: string; status: number } + +function getGameDir(libraryId: string, gameId: string): GameDirResult { + const library = getLibrary(libraryId) + if (!library) return { error: 'Library not found', status: 404 } + if (library.type !== 'games') return { error: 'Library is not a games library', status: 400 } + + const libraryRoot = resolveLibraryRoot(library) + const folderPath = decodeURIComponent(gameId) + + let gameDir: string + try { + gameDir = resolveAndJail(libraryRoot, folderPath) + } catch { + return { error: 'Invalid game path', status: 400 } + } + + if (!fs.existsSync(gameDir)) return { error: 'Game folder not found', status: 404 } + + return { gameDir, screenshotsDir: path.join(gameDir, 'screenshots'), folderPath } +} + +// ─── GET: list screenshots ──────────────────────────────────────────────────── + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl + const libraryId = searchParams.get('libraryId') + const gameId = searchParams.get('gameId') + + if (!libraryId || !gameId) { + return NextResponse.json({ error: 'Missing libraryId or gameId' }, { status: 400 }) + } + + const auth = await requireLibraryAccess(request, libraryId) + if (auth instanceof NextResponse) return auth + + const resolved = getGameDir(libraryId, gameId) + if ('error' in resolved) return NextResponse.json({ error: resolved.error }, { status: resolved.status }) + const { screenshotsDir, folderPath } = resolved + + if (!fs.existsSync(screenshotsDir)) { + return NextResponse.json({ screenshots: [] }) + } + + let files: string[] + try { + files = fs.readdirSync(screenshotsDir) + } catch { + return NextResponse.json({ screenshots: [] }) + } + + const screenshots = files + .filter((f) => SCREENSHOT_EXTENSIONS.has(path.extname(f).toLowerCase())) + .sort() + .map((filename) => { + const relPath = path.join(folderPath, 'screenshots', filename) + return { + filename, + url: fileApiUrl(libraryId, relPath), + thumbnailUrl: thumbnailApiUrl(libraryId, relPath), + } + }) + + return NextResponse.json({ screenshots }) +} + +// ─── POST: upload screenshot ────────────────────────────────────────────────── + +export async function POST(request: NextRequest) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + const { searchParams } = request.nextUrl + const libraryId = searchParams.get('libraryId') + const gameId = searchParams.get('gameId') + + if (!libraryId || !gameId) { + return NextResponse.json({ error: 'Missing libraryId or gameId' }, { status: 400 }) + } + + const resolved = getGameDir(libraryId, gameId) + if ('error' in resolved) return NextResponse.json({ error: resolved.error }, { status: resolved.status }) + const { screenshotsDir, folderPath } = resolved + + let formData: FormData + try { + formData = await request.formData() + } catch { + return NextResponse.json({ error: 'Invalid form data' }, { status: 400 }) + } + + const file = formData.get('screenshot') + if (!(file instanceof File)) { + return NextResponse.json({ error: 'screenshot field is required' }, { status: 400 }) + } + + if (file.size > MAX_SCREENSHOT_BYTES) { + return NextResponse.json({ error: 'File too large. Maximum size is 20 MB.' }, { status: 400 }) + } + + const rawBuffer = Buffer.from(await file.arrayBuffer()) + + let processedBuffer: Buffer + try { + processedBuffer = await sharp(rawBuffer).jpeg({ quality: 90 }).toBuffer() + } catch { + return NextResponse.json({ error: 'Invalid or corrupt image file.' }, { status: 400 }) + } + + fs.mkdirSync(screenshotsDir, { recursive: true }) + + const filename = `shot-${Date.now()}.jpg` + fs.writeFileSync(path.join(screenshotsDir, filename), processedBuffer) + + const relPath = path.join(folderPath, 'screenshots', filename) + return NextResponse.json( + { + filename, + url: fileApiUrl(libraryId, relPath), + thumbnailUrl: thumbnailApiUrl(libraryId, relPath), + }, + { status: 201 } + ) +} + +// ─── DELETE: remove screenshot ──────────────────────────────────────────────── + +export async function DELETE(request: NextRequest) { + const auth = await requireAdmin(request) + if (auth instanceof NextResponse) return auth + + const { searchParams } = request.nextUrl + const libraryId = searchParams.get('libraryId') + const gameId = searchParams.get('gameId') + const filename = searchParams.get('filename') + + if (!libraryId || !gameId || !filename) { + return NextResponse.json({ error: 'Missing libraryId, gameId, or filename' }, { status: 400 }) + } + + // Filename must be a plain basename — no path separators, no traversal + if (filename !== path.basename(filename) || filename.includes('..')) { + return NextResponse.json({ error: 'Invalid filename' }, { status: 400 }) + } + + const resolved = getGameDir(libraryId, gameId) + if ('error' in resolved) return NextResponse.json({ error: resolved.error }, { status: resolved.status }) + const { screenshotsDir } = resolved + + let filePath: string + try { + filePath = resolveAndJail(screenshotsDir, filename) + } catch { + return NextResponse.json({ error: 'Invalid filename' }, { status: 400 }) + } + + try { + fs.unlinkSync(filePath) + } catch { + return NextResponse.json({ error: 'File not found or could not be deleted' }, { status: 404 }) + } + + return new NextResponse(null, { status: 204 }) +} diff --git a/src/components/games/GameDetailModal.tsx b/src/components/games/GameDetailModal.tsx index 82b13f2..8d29e7e 100644 --- a/src/components/games/GameDetailModal.tsx +++ b/src/components/games/GameDetailModal.tsx @@ -27,6 +27,7 @@ interface Props { export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged, onCoverUploaded, onDeleted }: Props) { const overlayRef = useRef(null) const menuRef = useRef(null) + const screenshotInputRef = useRef(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(null) const [renameSaving, setRenameSaving] = useState(false) + // Screenshots state + const [screenshots, setScreenshots] = useState>([]) + const [screenshotsLoading, setScreenshotsLoading] = useState(false) + const [lightboxIndex, setLightboxIndex] = useState(null) + const [deletingScreenshot, setDeletingScreenshot] = useState(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) => { + 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 + {/* 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 */}

@@ -309,6 +441,75 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange )}

+ + {/* Lightbox */} + {lightboxIndex !== null && ( +
setLightboxIndex(null)} + > +
e.stopPropagation()} + > + {/* eslint-disable-next-line @next/next/no-img-element */} + {`Screenshot + + {/* Close */} + + + {/* Prev */} + {lightboxIndex > 0 && ( + + )} + + {/* Next */} + {lightboxIndex < screenshots.length - 1 && ( + + )} + + {/* Counter */} +
+ {lightboxIndex + 1} / {screenshots.length} +
+
+
+ )}
) }