add series grouping, cover upload, and multi-zip download to games library

- Series grouping: a top-level folder with no .zip but game subfolders is
  now treated as a GameSeries. Clicking a series drills into it with a
  breadcrumb; a game-count badge distinguishes series cards from game cards.
  Series fall back to the first game's cover when no series-level cover exists.
- Cover upload: new POST /api/game-cover endpoint writes cover.jpg or
  widecover.jpg directly into the game/series folder (re-encoded via sharp).
  A kebab menu on GameDetailModal opens an Edit Images panel showing previews
  and upload/replace buttons for both cover and wide cover.
- Multi-zip download: Game.zipFiles replaces zipPath and includes all .zip
  files in the folder. A single zip shows the existing download button; multiple
  zips render a split button — primary action downloads the first file, a
  dropdown arrow lists all files by name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Garret Patti
2026-04-05 12:49:42 -04:00
parent b254907cca
commit 122d7aa332
5 changed files with 739 additions and 160 deletions

View File

@@ -0,0 +1,99 @@
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'
const MAX_COVER_BYTES = 10 * 1024 * 1024 // 10 MB
type CoverType = 'cover' | 'widecover'
function isCoverType(s: string | null): s is CoverType {
return s === 'cover' || s === 'widecover'
}
export async function POST(request: NextRequest) {
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const itemId = searchParams.get('itemId')
const coverType = searchParams.get('coverType')
if (!libraryId || !itemId) {
return NextResponse.json({ error: 'Missing libraryId or itemId' }, { status: 400 })
}
if (!isCoverType(coverType)) {
return NextResponse.json({ error: 'coverType must be "cover" or "widecover"' }, { status: 400 })
}
const library = getLibrary(libraryId)
if (!library) {
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
}
if (library.type !== 'games') {
return NextResponse.json({ error: 'Library is not a games library' }, { status: 400 })
}
const libraryRoot = resolveLibraryRoot(library)
const folderPath = decodeURIComponent(itemId)
let resolvedDir: string
try {
resolvedDir = resolveAndJail(libraryRoot, folderPath)
} catch {
return NextResponse.json({ error: 'Invalid item path' }, { status: 400 })
}
if (!fs.existsSync(resolvedDir)) {
return NextResponse.json({ error: 'Game folder not found' }, { status: 404 })
}
let formData: FormData
try {
formData = await request.formData()
} catch {
return NextResponse.json({ error: 'Invalid form data' }, { status: 400 })
}
const file = formData.get('cover')
if (!(file instanceof File)) {
return NextResponse.json({ error: 'cover file is required' }, { status: 400 })
}
if (file.size > MAX_COVER_BYTES) {
return NextResponse.json({ error: 'File too large. Maximum size is 10 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 })
}
const destFilename = `${coverType}.jpg`
const destPath = path.join(resolvedDir, destFilename)
// Remove any existing file with the same base name but a different extension
const basePattern = new RegExp(`^${coverType}\\.`, 'i')
try {
for (const f of fs.readdirSync(resolvedDir)) {
if (basePattern.test(f) && f.toLowerCase() !== destFilename) {
fs.unlinkSync(path.join(resolvedDir, f))
}
}
} catch { /* ignore */ }
fs.writeFileSync(destPath, processedBuffer)
const relPath = path.join(folderPath, destFilename)
// cover uses the thumbnail endpoint; widecover is served directly
const url =
coverType === 'cover'
? `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relPath)}`
: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relPath)}`
return NextResponse.json({ url }, { status: 200 })
}