game-enhancements #17

Merged
gpatti merged 2 commits from game-enhancements into main 2026-04-12 14:19:51 +00:00
2 changed files with 379 additions and 1 deletions
Showing only changes of commit 84c65c7964 - Show all commits

View File

@@ -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 })
}

View File

@@ -27,6 +27,7 @@ interface Props {
export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged, onCoverUploaded, onDeleted }: Props) { export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged, onCoverUploaded, onDeleted }: Props) {
const overlayRef = useRef<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const screenshotInputRef = useRef<HTMLInputElement>(null)
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [editingImages, setEditingImages] = useState(false) const [editingImages, setEditingImages] = useState(false)
const [confirming, setConfirming] = 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 [renameError, setRenameError] = useState<string | null>(null)
const [renameSaving, setRenameSaving] = useState(false) 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(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { 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 (e.key === 'Escape') {
if (menuOpen) { setMenuOpen(false); return } if (menuOpen) { setMenuOpen(false); return }
if (confirming) { setConfirming(false); return } if (confirming) { setConfirming(false); return }
@@ -52,7 +110,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
document.removeEventListener('keydown', handleKey) document.removeEventListener('keydown', handleKey)
document.body.style.overflow = '' document.body.style.overflow = ''
} }
}, [onClose, menuOpen, editingImages, confirming, renaming]) }, [onClose, menuOpen, editingImages, confirming, renaming, lightboxIndex, screenshots.length])
// Close menu on outside click // Close menu on outside click
useEffect(() => { useEffect(() => {
@@ -298,6 +356,80 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
<DownloadButton gameFiles={game.gameFiles} clientPlatform={clientPlatform} downloadUrl={fileDownloadUrl} /> <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 */} {/* Tags */}
<div className="mt-4 pt-4" style={{ borderTop: '1px solid var(--border)' }}> <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)' }}> <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> </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> </div>
) )
} }