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 })
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import type { Game } from '@/types'
|
import type { Game } from '@/types'
|
||||||
import TagSelector from '@/components/tags/TagSelector'
|
import TagSelector from '@/components/tags/TagSelector'
|
||||||
|
|
||||||
@@ -9,14 +9,22 @@ interface Props {
|
|||||||
libraryId: string
|
libraryId: string
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onTagsChanged?: () => void
|
onTagsChanged?: () => void
|
||||||
|
onCoverUploaded?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged }: Props) {
|
export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged, onCoverUploaded }: Props) {
|
||||||
const overlayRef = useRef<HTMLDivElement>(null)
|
const overlayRef = useRef<HTMLDivElement>(null)
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const [editingImages, setEditingImages] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose()
|
if (e.key === 'Escape') {
|
||||||
|
if (menuOpen) { setMenuOpen(false); return }
|
||||||
|
if (editingImages) { setEditingImages(false); return }
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', handleKey)
|
document.addEventListener('keydown', handleKey)
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden'
|
||||||
@@ -24,13 +32,27 @@ 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])
|
}, [onClose, menuOpen, editingImages])
|
||||||
|
|
||||||
|
// Close menu on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuOpen) return
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
setMenuOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [menuOpen])
|
||||||
|
|
||||||
const handleOverlayClick = (e: React.MouseEvent) => {
|
const handleOverlayClick = (e: React.MouseEvent) => {
|
||||||
if (e.target === overlayRef.current) onClose()
|
if (e.target === overlayRef.current) onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadHref = `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(game.zipPath)}`
|
const zipDownloadUrl = (zipPath: string) =>
|
||||||
|
`/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(zipPath)}`
|
||||||
|
const heroImage = game.wideCoverUrl ?? game.coverUrl
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -43,6 +65,15 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
|||||||
className="relative w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
|
className="relative w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl"
|
||||||
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
>
|
>
|
||||||
|
{editingImages ? (
|
||||||
|
<ImageEditor
|
||||||
|
game={game}
|
||||||
|
libraryId={libraryId}
|
||||||
|
onBack={() => setEditingImages(false)}
|
||||||
|
onUploaded={onCoverUploaded}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -55,22 +86,11 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
|||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Wide cover / cover hero */}
|
{/* Hero image */}
|
||||||
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
|
<div className="w-full" style={{ backgroundColor: 'var(--border)' }}>
|
||||||
{game.wideCoverUrl ? (
|
{heroImage ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img src={heroImage} alt={`${game.title} cover`} className="w-full object-cover max-h-64" />
|
||||||
src={game.wideCoverUrl}
|
|
||||||
alt={`${game.title} wide cover`}
|
|
||||||
className="w-full object-cover max-h-64"
|
|
||||||
/>
|
|
||||||
) : game.coverUrl ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={game.coverUrl}
|
|
||||||
alt={`${game.title} cover`}
|
|
||||||
className="w-full object-contain max-h-64"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="h-40 flex items-center justify-center text-5xl">🎮</div>
|
<div className="h-40 flex items-center justify-center text-5xl">🎮</div>
|
||||||
)}
|
)}
|
||||||
@@ -78,20 +98,44 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
|||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<h2 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
{/* Title row with kebab menu */}
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<h2 className="text-lg font-semibold flex-1 min-w-0" style={{ color: 'var(--text-primary)' }}>
|
||||||
{game.title}
|
{game.title}
|
||||||
</h2>
|
</h2>
|
||||||
<a
|
|
||||||
href={downloadHref}
|
{/* Kebab menu */}
|
||||||
download
|
<div className="relative flex-shrink-0" ref={menuRef}>
|
||||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg font-medium text-sm transition-colors"
|
<button
|
||||||
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
onClick={() => setMenuOpen((o) => !o)}
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')}
|
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors"
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
|
aria-label="More options"
|
||||||
>
|
>
|
||||||
<span>↓</span>
|
⋮
|
||||||
Download .zip
|
</button>
|
||||||
</a>
|
{menuOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
|
||||||
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => { setMenuOpen(false); setEditingImages(true) }}
|
||||||
|
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
|
>
|
||||||
|
Edit images
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DownloadButton zipFiles={game.zipFiles} downloadUrl={zipDownloadUrl} />
|
||||||
|
|
||||||
{/* 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)' }}>
|
||||||
@@ -101,7 +145,287 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
|||||||
<TagSelector mediaKey={`${libraryId}:${game.id}`} onTagsChanged={onTagsChanged} />
|
<TagSelector mediaKey={`${libraryId}:${game.id}`} onTagsChanged={onTagsChanged} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Download Button ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DownloadButton({
|
||||||
|
zipFiles,
|
||||||
|
downloadUrl,
|
||||||
|
}: {
|
||||||
|
zipFiles: string[]
|
||||||
|
downloadUrl: (zipPath: string) => string
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const close = useCallback(() => setOpen(false), [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) close()
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [open, close])
|
||||||
|
|
||||||
|
const primary = zipFiles[0]
|
||||||
|
const primaryName = primary.split('/').pop() ?? primary
|
||||||
|
|
||||||
|
if (zipFiles.length === 1) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={downloadUrl(primary)}
|
||||||
|
download
|
||||||
|
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg font-medium text-sm transition-colors"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
|
||||||
|
>
|
||||||
|
<span>↓</span>
|
||||||
|
Download .zip
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<div className="flex rounded-lg overflow-hidden" style={{ backgroundColor: 'var(--accent)' }}>
|
||||||
|
{/* Primary download */}
|
||||||
|
<a
|
||||||
|
href={downloadUrl(primary)}
|
||||||
|
download
|
||||||
|
className="flex items-center justify-center gap-2 flex-1 px-4 py-2.5 font-medium text-sm transition-colors"
|
||||||
|
style={{ color: '#fff' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.1)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
|
>
|
||||||
|
<span>↓</span>
|
||||||
|
{primaryName}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div style={{ width: '1px', backgroundColor: 'rgba(255,255,255,0.25)' }} />
|
||||||
|
|
||||||
|
{/* Dropdown toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="px-3 flex items-center justify-center text-sm transition-colors"
|
||||||
|
style={{ color: '#fff' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.1)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
|
aria-label="Show all downloads"
|
||||||
|
>
|
||||||
|
▾
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20"
|
||||||
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
{zipFiles.map((zipPath) => {
|
||||||
|
const name = zipPath.split('/').pop() ?? zipPath
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={zipPath}
|
||||||
|
href={downloadUrl(zipPath)}
|
||||||
|
download
|
||||||
|
onClick={close}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
|
>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>↓</span>
|
||||||
|
{name}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Image Editor ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ImageEditorProps {
|
||||||
|
game: Game
|
||||||
|
libraryId: string
|
||||||
|
onBack: () => void
|
||||||
|
onUploaded?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageEditor({ game, libraryId, onBack, onUploaded }: ImageEditorProps) {
|
||||||
|
return (
|
||||||
|
<div className="p-5">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors flex-shrink-0"
|
||||||
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'transparent' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
|
aria-label="Back"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
Edit Images
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<ImageSlot
|
||||||
|
label="Cover"
|
||||||
|
description="Portrait artwork (3:4)"
|
||||||
|
currentUrl={game.coverUrl}
|
||||||
|
fallback="🎮"
|
||||||
|
aspectClass="aspect-[3/4]"
|
||||||
|
libraryId={libraryId}
|
||||||
|
itemId={game.id}
|
||||||
|
coverType="cover"
|
||||||
|
onUploaded={onUploaded}
|
||||||
|
/>
|
||||||
|
<ImageSlot
|
||||||
|
label="Wide Cover"
|
||||||
|
description="Landscape hero image"
|
||||||
|
currentUrl={game.wideCoverUrl}
|
||||||
|
fallback="🎮"
|
||||||
|
aspectClass="aspect-video"
|
||||||
|
libraryId={libraryId}
|
||||||
|
itemId={game.id}
|
||||||
|
coverType="widecover"
|
||||||
|
onUploaded={onUploaded}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Image Slot ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ImageSlotProps {
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
currentUrl: string | null
|
||||||
|
fallback: string
|
||||||
|
aspectClass: string
|
||||||
|
libraryId: string
|
||||||
|
itemId: string
|
||||||
|
coverType: 'cover' | 'widecover'
|
||||||
|
onUploaded?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageSlot({
|
||||||
|
label, description, currentUrl, fallback, aspectClass,
|
||||||
|
libraryId, itemId, coverType, onUploaded,
|
||||||
|
}: ImageSlotProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [preview, setPreview] = useState<string | null>(null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const displayUrl = preview ?? currentUrl
|
||||||
|
|
||||||
|
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
// Show local preview immediately
|
||||||
|
const objectUrl = URL.createObjectURL(file)
|
||||||
|
setPreview(objectUrl)
|
||||||
|
setError(null)
|
||||||
|
setUploading(true)
|
||||||
|
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('cover', file)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/game-cover?libraryId=${encodeURIComponent(libraryId)}&itemId=${encodeURIComponent(itemId)}&coverType=${coverType}`,
|
||||||
|
{ method: 'POST', body: form }
|
||||||
|
)
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
setError(data.error ?? 'Upload failed.')
|
||||||
|
setPreview(null)
|
||||||
|
} else {
|
||||||
|
onUploaded?.()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Network error.')
|
||||||
|
setPreview(null)
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between mb-2">
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>{description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* Preview */}
|
||||||
|
<div
|
||||||
|
className={`${aspectClass} rounded-lg overflow-hidden flex-shrink-0 relative`}
|
||||||
|
style={{
|
||||||
|
width: coverType === 'cover' ? '80px' : '160px',
|
||||||
|
backgroundColor: 'var(--border)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={displayUrl} alt={label} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-2xl">{fallback}</div>
|
||||||
|
)}
|
||||||
|
{uploading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||||
|
<span className="text-xs text-white">Saving…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex flex-col gap-2 flex-1 min-w-0">
|
||||||
|
<button
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 text-left"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
|
>
|
||||||
|
{currentUrl || preview ? 'Replace image' : 'Upload image'}
|
||||||
|
</button>
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs" style={{ color: '#fca5a5' }}>{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
import type { Game } from '@/types'
|
import type { Game, GameSeries } from '@/types'
|
||||||
import GameDetailModal from './GameDetailModal'
|
import GameDetailModal from './GameDetailModal'
|
||||||
import FilterPanel from '@/components/FilterPanel'
|
import FilterPanel from '@/components/FilterPanel'
|
||||||
|
|
||||||
@@ -10,10 +10,13 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function GamesView({ libraryId }: Props) {
|
export default function GamesView({ libraryId }: Props) {
|
||||||
const [games, setGames] = useState<Game[]>([])
|
const [items, setItems] = useState<(Game | GameSeries)[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [selectedSeries, setSelectedSeries] = useState<GameSeries | null>(null)
|
||||||
const [selected, setSelected] = useState<Game | null>(null)
|
const [selected, setSelected] = useState<Game | null>(null)
|
||||||
|
const selectedRef = useRef(selected)
|
||||||
|
selectedRef.current = selected
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||||
@@ -26,12 +29,26 @@ export default function GamesView({ libraryId }: Props) {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchGames = useCallback((syncSelected = false) => {
|
||||||
fetch(`/api/games?libraryId=${encodeURIComponent(libraryId)}`)
|
fetch(`/api/games?libraryId=${encodeURIComponent(libraryId)}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => {
|
.then((data: (Game | GameSeries)[]) => {
|
||||||
setGames(data)
|
setItems(data)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
if (syncSelected && selectedRef.current) {
|
||||||
|
const id = selectedRef.current.id
|
||||||
|
// Search top-level games and inside series
|
||||||
|
let updated: Game | undefined
|
||||||
|
for (const item of data) {
|
||||||
|
if ('games' in item) {
|
||||||
|
updated = item.games.find((g) => g.id === id)
|
||||||
|
} else if (item.id === id) {
|
||||||
|
updated = item
|
||||||
|
}
|
||||||
|
if (updated) break
|
||||||
|
}
|
||||||
|
if (updated) setSelected(updated)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setError('Failed to load games')
|
setError('Failed to load games')
|
||||||
@@ -39,6 +56,8 @@ export default function GamesView({ libraryId }: Props) {
|
|||||||
})
|
})
|
||||||
}, [libraryId])
|
}, [libraryId])
|
||||||
|
|
||||||
|
useEffect(() => { fetchGames() }, [fetchGames])
|
||||||
|
|
||||||
const fetchAssignments = useCallback(() => {
|
const fetchAssignments = useCallback(() => {
|
||||||
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -48,10 +67,17 @@ export default function GamesView({ libraryId }: Props) {
|
|||||||
|
|
||||||
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
||||||
|
|
||||||
const filtered = games.filter((game) => {
|
// Items shown in the current view level
|
||||||
if (search && !game.title.toLowerCase().includes(search.toLowerCase())) return false
|
const visibleItems: (Game | GameSeries)[] = selectedSeries
|
||||||
|
? selectedSeries.games
|
||||||
|
: items
|
||||||
|
|
||||||
|
const filtered = visibleItems.filter((item) => {
|
||||||
|
if (search && !item.title.toLowerCase().includes(search.toLowerCase())) return false
|
||||||
if (selectedTagIds.size > 0) {
|
if (selectedTagIds.size > 0) {
|
||||||
const gameTags = assignments[`${libraryId}:${game.id}`] ?? []
|
// Tag filtering only applies to games (series don't have tags directly)
|
||||||
|
if ('games' in item) return true
|
||||||
|
const gameTags = assignments[`${libraryId}:${item.id}`] ?? []
|
||||||
if (![...selectedTagIds].every((id) => gameTags.includes(id))) return false
|
if (![...selectedTagIds].every((id) => gameTags.includes(id))) return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -71,23 +97,74 @@ export default function GamesView({ libraryId }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Breadcrumb when inside a series */}
|
||||||
|
{selectedSeries && (
|
||||||
|
<div className="flex items-center gap-2 mb-4 text-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedSeries(null); setSearch('') }}
|
||||||
|
className="transition-colors"
|
||||||
|
style={{ color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
All Games
|
||||||
|
</button>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>/</span>
|
||||||
|
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{selectedSeries.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<LoadingGrid />
|
<LoadingGrid />
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<ErrorMessage message={error} />
|
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||||
) : games.length === 0 ? (
|
{error}
|
||||||
<EmptyState />
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="rounded-lg border p-12 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
||||||
|
<p className="text-lg mb-1">No games found</p>
|
||||||
|
<p className="text-sm">Each game should be a folder containing a .zip file.</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
<div className="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||||
{filtered.map((game) => (
|
{filtered.map((item) =>
|
||||||
|
'games' in item ? (
|
||||||
|
<SeriesCard
|
||||||
|
key={item.id}
|
||||||
|
series={item}
|
||||||
|
onClick={() => { setSelectedSeries(item); setSearch('') }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<GameCard
|
||||||
|
key={item.id}
|
||||||
|
game={item}
|
||||||
|
onClick={() => setSelected(item)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<GameDetailModal
|
||||||
|
game={selected}
|
||||||
|
libraryId={libraryId}
|
||||||
|
onClose={() => setSelected(null)}
|
||||||
|
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
||||||
|
onCoverUploaded={() => fetchGames(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GameCard({ game, onClick }: { game: Game; onClick: () => void }) {
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={game.id}
|
onClick={onClick}
|
||||||
onClick={() => setSelected(game)}
|
|
||||||
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
|
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
|
||||||
style={{
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
||||||
borderColor: 'var(--border)',
|
|
||||||
backgroundColor: 'var(--surface)',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
||||||
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
|
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
|
||||||
@@ -100,41 +177,59 @@ export default function GamesView({ libraryId }: Props) {
|
|||||||
<div className="aspect-[3/4] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
|
<div className="aspect-[3/4] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
|
||||||
{game.coverUrl ? (
|
{game.coverUrl ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img src={game.coverUrl} alt={game.title} className="absolute inset-0 w-full h-full object-cover" />
|
||||||
src={game.coverUrl}
|
|
||||||
alt={game.title}
|
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-4xl">
|
<div className="absolute inset-0 flex items-center justify-center text-4xl">🎮</div>
|
||||||
🎮
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<p
|
<p className="text-xs font-medium truncate leading-tight" style={{ color: 'var(--text-primary)' }} title={game.title}>
|
||||||
className="text-xs font-medium truncate leading-tight"
|
|
||||||
style={{ color: 'var(--text-primary)' }}
|
|
||||||
title={game.title}
|
|
||||||
>
|
|
||||||
{game.title}
|
{game.title}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
)
|
||||||
</div>
|
}
|
||||||
)}
|
|
||||||
|
|
||||||
{selected && (
|
function SeriesCard({ series, onClick }: { series: GameSeries; onClick: () => void }) {
|
||||||
<GameDetailModal
|
return (
|
||||||
game={selected}
|
<button
|
||||||
libraryId={libraryId}
|
onClick={onClick}
|
||||||
onClose={() => setSelected(null)}
|
className="group text-left rounded-xl overflow-hidden border transition-all focus:outline-none focus-visible:ring-2"
|
||||||
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
style={{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }}
|
||||||
/>
|
onMouseEnter={(e) => {
|
||||||
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
;(e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
|
||||||
|
;(e.currentTarget as HTMLElement).style.transform = 'translateY(0)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="aspect-[3/4] w-full relative overflow-hidden" style={{ backgroundColor: 'var(--border)' }}>
|
||||||
|
{series.coverUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={series.coverUrl} alt={series.title} className="absolute inset-0 w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-4xl">🎮</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Game count badge */}
|
||||||
|
<div
|
||||||
|
className="absolute bottom-1.5 right-1.5 px-1.5 py-0.5 rounded text-xs font-semibold"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
{series.games.length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="p-2">
|
||||||
|
<p className="text-xs font-medium truncate leading-tight" style={{ color: 'var(--text-primary)' }} title={series.title}>
|
||||||
|
{series.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
Series
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,20 +247,3 @@ function LoadingGrid() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ErrorMessage({ message }: { message: string }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border p-8 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyState() {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border p-12 text-center" style={{ borderColor: 'var(--border)', color: 'var(--text-secondary)' }}>
|
|
||||||
<p className="text-lg mb-1">No games found</p>
|
|
||||||
<p className="text-sm">Each game should be a folder containing a .zip file.</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
124
src/lib/games.ts
124
src/lib/games.ts
@@ -1,6 +1,6 @@
|
|||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import type { Game } from '@/types'
|
import type { Game, GameSeries } from '@/types'
|
||||||
|
|
||||||
const HIDDEN_FILES = /^\./
|
const HIDDEN_FILES = /^\./
|
||||||
|
|
||||||
@@ -25,10 +25,56 @@ function fileApiUrl(libraryId: string, relativePath: string): string {
|
|||||||
return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
|
return `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scanGamesLibrary(libraryRoot: string, libraryId: string): Game[] {
|
function thumbnailApiUrl(libraryId: string, relativePath: string): string {
|
||||||
let gameDirs: string[]
|
return `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relativePath)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to build a Game from a directory.
|
||||||
|
* @param absPath Absolute path to the game directory.
|
||||||
|
* @param dirName The directory's own name (used as title).
|
||||||
|
* @param relPath Path relative to the library root (used for IDs and file URLs).
|
||||||
|
* @param libraryId Library identifier.
|
||||||
|
* @returns Game, or null if the directory contains no .zip file.
|
||||||
|
*/
|
||||||
|
function buildGame(
|
||||||
|
absPath: string,
|
||||||
|
dirName: string,
|
||||||
|
relPath: string,
|
||||||
|
libraryId: string
|
||||||
|
): Game | null {
|
||||||
|
let allFiles: string[]
|
||||||
try {
|
try {
|
||||||
gameDirs = fs
|
allFiles = fs.readdirSync(absPath)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipFiles = allFiles
|
||||||
|
.filter((f) => f.toLowerCase().endsWith('.zip'))
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
if (zipFiles.length === 0) return null
|
||||||
|
|
||||||
|
const coverFile = findFile(absPath, /^cover$/i)
|
||||||
|
const wideCoverFile = findFile(absPath, /^widecover$/i)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: encodeURIComponent(relPath),
|
||||||
|
title: dirName,
|
||||||
|
coverUrl: coverFile
|
||||||
|
? thumbnailApiUrl(libraryId, path.join(relPath, coverFile))
|
||||||
|
: null,
|
||||||
|
wideCoverUrl: wideCoverFile
|
||||||
|
? fileApiUrl(libraryId, path.join(relPath, wideCoverFile))
|
||||||
|
: null,
|
||||||
|
zipFiles: zipFiles.map((f) => path.join(relPath, f)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanGamesLibrary(libraryRoot: string, libraryId: string): (Game | GameSeries)[] {
|
||||||
|
let topDirs: string[]
|
||||||
|
try {
|
||||||
|
topDirs = fs
|
||||||
.readdirSync(libraryRoot, { withFileTypes: true })
|
.readdirSync(libraryRoot, { withFileTypes: true })
|
||||||
.filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name))
|
.filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name))
|
||||||
.map((d) => d.name)
|
.map((d) => d.name)
|
||||||
@@ -36,42 +82,66 @@ export function scanGamesLibrary(libraryRoot: string, libraryId: string): Game[]
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const games: Game[] = []
|
const results: (Game | GameSeries)[] = []
|
||||||
|
|
||||||
for (const dirName of gameDirs) {
|
for (const dirName of topDirs) {
|
||||||
const gamePath = path.join(libraryRoot, dirName)
|
const absPath = path.join(libraryRoot, dirName)
|
||||||
|
|
||||||
// Find the .zip file (first match)
|
let allFiles: string[]
|
||||||
let zipFile: string | null = null
|
|
||||||
try {
|
try {
|
||||||
const allFiles = fs.readdirSync(gamePath)
|
allFiles = fs.readdirSync(absPath)
|
||||||
zipFile = allFiles.find((f) => f.toLowerCase().endsWith('.zip')) ?? null
|
|
||||||
} catch {
|
} catch {
|
||||||
// skip unreadable dirs
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!zipFile) continue
|
// Standalone game: directory directly contains a .zip
|
||||||
|
const hasZip = allFiles.some((f) => f.toLowerCase().endsWith('.zip'))
|
||||||
|
if (hasZip) {
|
||||||
|
const game = buildGame(absPath, dirName, dirName, libraryId)
|
||||||
|
if (game) results.push(game)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Case-insensitive cover matching
|
// No .zip here — check subdirectories (series detection)
|
||||||
const coverFile = findFile(gamePath, /^cover$/i)
|
let subDirs: string[]
|
||||||
const wideCoverFile = findFile(gamePath, /^widecover$/i)
|
try {
|
||||||
|
subDirs = fs
|
||||||
|
.readdirSync(absPath, { withFileTypes: true })
|
||||||
|
.filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name))
|
||||||
|
.map((d) => d.name)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const id = encodeURIComponent(dirName)
|
const seriesGames: Game[] = []
|
||||||
const zipRelPath = path.join(dirName, zipFile)
|
for (const subDir of subDirs) {
|
||||||
|
const game = buildGame(
|
||||||
|
path.join(absPath, subDir),
|
||||||
|
subDir,
|
||||||
|
path.join(dirName, subDir),
|
||||||
|
libraryId
|
||||||
|
)
|
||||||
|
if (game) seriesGames.push(game)
|
||||||
|
}
|
||||||
|
|
||||||
games.push({
|
if (seriesGames.length === 0) continue
|
||||||
id,
|
|
||||||
|
// It's a series — check for an optional series-level cover
|
||||||
|
const seriesCoverFile = findFile(absPath, /^cover$/i)
|
||||||
|
const seriesWideCoverFile = findFile(absPath, /^widecover$/i)
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
id: encodeURIComponent(dirName),
|
||||||
title: dirName,
|
title: dirName,
|
||||||
coverUrl: coverFile
|
coverUrl: seriesCoverFile
|
||||||
? fileApiUrl(libraryId, path.join(dirName, coverFile))
|
? thumbnailApiUrl(libraryId, path.join(dirName, seriesCoverFile))
|
||||||
|
: seriesGames[0].coverUrl,
|
||||||
|
wideCoverUrl: seriesWideCoverFile
|
||||||
|
? fileApiUrl(libraryId, path.join(dirName, seriesWideCoverFile))
|
||||||
: null,
|
: null,
|
||||||
wideCoverUrl: wideCoverFile
|
games: seriesGames.sort((a, b) => a.title.localeCompare(b.title)),
|
||||||
? fileApiUrl(libraryId, path.join(dirName, wideCoverFile))
|
|
||||||
: null,
|
|
||||||
zipPath: zipRelPath,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return games.sort((a, b) => a.title.localeCompare(b.title))
|
return results.sort((a, b) => a.title.localeCompare(b.title))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,15 @@ export interface Game {
|
|||||||
title: string
|
title: string
|
||||||
coverUrl: string | null
|
coverUrl: string | null
|
||||||
wideCoverUrl: string | null
|
wideCoverUrl: string | null
|
||||||
zipPath: string
|
zipFiles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameSeries {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
coverUrl: string | null
|
||||||
|
wideCoverUrl: string | null
|
||||||
|
games: Game[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MediaType = 'video' | 'image' | 'other'
|
export type MediaType = 'video' | 'image' | 'other'
|
||||||
|
|||||||
Reference in New Issue
Block a user