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:
99
src/app/api/game-cover/route.ts
Normal file
99
src/app/api/game-cover/route.ts
Normal 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 })
|
||||
}
|
||||
Reference in New Issue
Block a user