game-enhancements #17
1020
package-lock.json
generated
1020
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"archiver": "^7.0.1",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
"fast-xml-parser": "^5.5.10",
|
"fast-xml-parser": "^5.5.10",
|
||||||
"iron-session": "^8.0.4",
|
"iron-session": "^8.0.4",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.2.2",
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
|
"@types/archiver": "^7.0.0",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import archiver from 'archiver'
|
||||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryAccess } from '@/lib/auth'
|
||||||
|
|
||||||
@@ -20,13 +21,22 @@ const MIME_TYPES: Record<string, string> = {
|
|||||||
'.tiff': 'image/tiff',
|
'.tiff': 'image/tiff',
|
||||||
'.tif': 'image/tiff',
|
'.tif': 'image/tiff',
|
||||||
'.zip': 'application/zip',
|
'.zip': 'application/zip',
|
||||||
|
'.dmg': 'application/x-apple-diskimage',
|
||||||
|
'.gz': 'application/gzip',
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMimeType(filePath: string): string {
|
function getMimeType(filePath: string): string {
|
||||||
|
// Special-case .tar.gz before checking the last extension
|
||||||
|
if (filePath.toLowerCase().endsWith('.tar.gz')) return 'application/gzip'
|
||||||
const ext = path.extname(filePath).toLowerCase()
|
const ext = path.extname(filePath).toLowerCase()
|
||||||
return MIME_TYPES[ext] ?? 'application/octet-stream'
|
return MIME_TYPES[ext] ?? 'application/octet-stream'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDownloadAttachment(filePath: string): boolean {
|
||||||
|
const lower = filePath.toLowerCase()
|
||||||
|
return lower.endsWith('.zip') || lower.endsWith('.tar.gz') || lower.endsWith('.dmg')
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = request.nextUrl
|
const { searchParams } = request.nextUrl
|
||||||
const libraryId = searchParams.get('libraryId')
|
const libraryId = searchParams.get('libraryId')
|
||||||
@@ -60,6 +70,25 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// .app bundle: stream the directory as a zip archive on the fly
|
||||||
|
if (stat.isDirectory() && subpath.toLowerCase().endsWith('.app')) {
|
||||||
|
const bundleName = path.basename(filePath)
|
||||||
|
const zipName = `${bundleName}.zip`
|
||||||
|
|
||||||
|
const archive = archiver('zip', { zlib: { level: 6 } })
|
||||||
|
archive.directory(filePath, bundleName)
|
||||||
|
archive.finalize()
|
||||||
|
|
||||||
|
return new NextResponse(archive as unknown as ReadableStream, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/zip',
|
||||||
|
'Content-Disposition': `attachment; filename="${encodeURIComponent(zipName)}"`,
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (!stat.isFile()) {
|
if (!stat.isFile()) {
|
||||||
return NextResponse.json({ error: 'Not a file' }, { status: 400 })
|
return NextResponse.json({ error: 'Not a file' }, { status: 400 })
|
||||||
}
|
}
|
||||||
@@ -68,9 +97,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const fileSize = stat.size
|
const fileSize = stat.size
|
||||||
const rangeHeader = request.headers.get('range')
|
const rangeHeader = request.headers.get('range')
|
||||||
|
|
||||||
// Handle ZIP as a download
|
const contentDisposition = isDownloadAttachment(filePath)
|
||||||
const isZip = path.extname(filePath).toLowerCase() === '.zip'
|
|
||||||
const contentDisposition = isZip
|
|
||||||
? `attachment; filename="${encodeURIComponent(path.basename(filePath))}"`
|
? `attachment; filename="${encodeURIComponent(path.basename(filePath))}"`
|
||||||
: `inline; filename="${encodeURIComponent(path.basename(filePath))}"`
|
: `inline; filename="${encodeURIComponent(path.basename(filePath))}"`
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import sharp from 'sharp'
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||||
import { requireAdmin } from '@/lib/auth'
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
|
||||||
const MAX_COVER_BYTES = 10 * 1024 * 1024 // 10 MB
|
const MAX_COVER_BYTES = 10 * 1024 * 1024 // 10 MB
|
||||||
|
|
||||||
@@ -99,5 +100,16 @@ export async function POST(request: NextRequest) {
|
|||||||
? `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relPath)}`
|
? `/api/thumbnail?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relPath)}`
|
||||||
: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relPath)}`
|
: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(relPath)}`
|
||||||
|
|
||||||
|
// Update DB metadata so the new cover is visible without a re-scan
|
||||||
|
const db = getDb()
|
||||||
|
const itemKey = `${libraryId}:game:${itemId}`
|
||||||
|
const row = db.prepare('SELECT metadata FROM media_items WHERE item_key = ?').get(itemKey) as { metadata: string | null } | undefined
|
||||||
|
if (row) {
|
||||||
|
const meta = row.metadata ? JSON.parse(row.metadata) : {}
|
||||||
|
if (coverType === 'cover') meta.coverUrl = url
|
||||||
|
else meta.wideCoverUrl = url
|
||||||
|
db.prepare('UPDATE media_items SET metadata = ? WHERE item_key = ?').run(JSON.stringify(meta), itemKey)
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ url }, { status: 200 })
|
return NextResponse.json({ url }, { status: 200 })
|
||||||
}
|
}
|
||||||
|
|||||||
177
src/app/api/game-screenshots/route.ts
Normal file
177
src/app/api/game-screenshots/route.ts
Normal 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 })
|
||||||
|
}
|
||||||
@@ -1,9 +1,20 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import type { Game } from '@/types'
|
import type { Game, GameFile, GamePlatform } from '@/types'
|
||||||
import TagSelector from '@/components/tags/TagSelector'
|
import TagSelector from '@/components/tags/TagSelector'
|
||||||
|
|
||||||
|
const PLATFORM_LABELS: Record<GamePlatform, string> = {
|
||||||
|
windows: 'WIN',
|
||||||
|
linux: 'LIN',
|
||||||
|
macos: 'MAC',
|
||||||
|
}
|
||||||
|
const PLATFORM_COLORS: Record<GamePlatform, string> = {
|
||||||
|
windows: '#0078d4',
|
||||||
|
linux: '#e95420',
|
||||||
|
macos: '#6e6e73',
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
game: Game
|
game: Game
|
||||||
libraryId: string
|
libraryId: string
|
||||||
@@ -16,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)
|
||||||
@@ -25,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 }
|
||||||
@@ -41,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(() => {
|
||||||
@@ -59,8 +128,16 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
|||||||
if (e.target === overlayRef.current) onClose()
|
if (e.target === overlayRef.current) onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const zipDownloadUrl = (zipPath: string) =>
|
const [clientPlatform, setClientPlatform] = useState<GamePlatform | null>(null)
|
||||||
`/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(zipPath)}`
|
useEffect(() => {
|
||||||
|
const p = navigator.platform.toLowerCase()
|
||||||
|
if (p.startsWith('win')) setClientPlatform('windows')
|
||||||
|
else if (p.startsWith('mac') || p.includes('iphone') || p.includes('ipad')) setClientPlatform('macos')
|
||||||
|
else setClientPlatform('linux')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fileDownloadUrl = (filePath: string) =>
|
||||||
|
`/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(filePath)}`
|
||||||
const heroImage = game.wideCoverUrl ?? game.coverUrl
|
const heroImage = game.wideCoverUrl ?? game.coverUrl
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -277,7 +354,81 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DownloadButton zipFiles={game.zipFiles} downloadUrl={zipDownloadUrl} />
|
<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)' }}>
|
||||||
@@ -290,18 +441,100 @@ 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Download Button ──────────────────────────────────────────────────────────
|
// ─── Download Button ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PlatformPill({ platform }: { platform: GamePlatform }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="px-1.5 py-0.5 rounded text-xs font-bold leading-none flex-shrink-0"
|
||||||
|
style={{ backgroundColor: PLATFORM_COLORS[platform], color: '#fff' }}
|
||||||
|
>
|
||||||
|
{PLATFORM_LABELS[platform]}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function DownloadButton({
|
function DownloadButton({
|
||||||
zipFiles,
|
gameFiles,
|
||||||
|
clientPlatform,
|
||||||
downloadUrl,
|
downloadUrl,
|
||||||
}: {
|
}: {
|
||||||
zipFiles: string[]
|
gameFiles: GameFile[]
|
||||||
downloadUrl: (zipPath: string) => string
|
clientPlatform: GamePlatform | null
|
||||||
|
downloadUrl: (filePath: string) => string
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
@@ -317,13 +550,17 @@ function DownloadButton({
|
|||||||
return () => document.removeEventListener('mousedown', handler)
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
}, [open, close])
|
}, [open, close])
|
||||||
|
|
||||||
const primary = zipFiles[0]
|
if (gameFiles.length === 0) return null
|
||||||
const primaryName = primary.split('/').pop() ?? primary
|
|
||||||
|
|
||||||
if (zipFiles.length === 1) {
|
// Pick primary: first file matching clientPlatform, or first overall
|
||||||
|
const primary =
|
||||||
|
(clientPlatform ? gameFiles.find((f) => f.platform === clientPlatform) : null) ??
|
||||||
|
gameFiles[0]
|
||||||
|
|
||||||
|
if (gameFiles.length === 1) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={downloadUrl(primary)}
|
href={downloadUrl(primary.path)}
|
||||||
download
|
download
|
||||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg font-medium text-sm transition-colors"
|
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' }}
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
@@ -331,7 +568,8 @@ function DownloadButton({
|
|||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)')}
|
||||||
>
|
>
|
||||||
<span>↓</span>
|
<span>↓</span>
|
||||||
Download .zip
|
<PlatformPill platform={primary.platform} />
|
||||||
|
Download {primary.filename}
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -341,15 +579,16 @@ function DownloadButton({
|
|||||||
<div className="flex rounded-lg overflow-hidden" style={{ backgroundColor: 'var(--accent)' }}>
|
<div className="flex rounded-lg overflow-hidden" style={{ backgroundColor: 'var(--accent)' }}>
|
||||||
{/* Primary download */}
|
{/* Primary download */}
|
||||||
<a
|
<a
|
||||||
href={downloadUrl(primary)}
|
href={downloadUrl(primary.path)}
|
||||||
download
|
download
|
||||||
className="flex items-center justify-center gap-2 flex-1 px-4 py-2.5 font-medium text-sm transition-colors"
|
className="flex items-center gap-2 flex-1 px-4 py-2.5 font-medium text-sm transition-colors min-w-0"
|
||||||
style={{ color: '#fff' }}
|
style={{ color: '#fff' }}
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.1)')}
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.1)')}
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
>
|
>
|
||||||
<span>↓</span>
|
<span className="flex-shrink-0">↓</span>
|
||||||
{primaryName}
|
<PlatformPill platform={primary.platform} />
|
||||||
|
<span className="truncate">{primary.filename}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
@@ -358,7 +597,7 @@ function DownloadButton({
|
|||||||
{/* Dropdown toggle */}
|
{/* Dropdown toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen((o) => !o)}
|
onClick={() => setOpen((o) => !o)}
|
||||||
className="px-3 flex items-center justify-center text-sm transition-colors"
|
className="px-3 flex items-center justify-center text-sm transition-colors flex-shrink-0"
|
||||||
style={{ color: '#fff' }}
|
style={{ color: '#fff' }}
|
||||||
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.1)')}
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'rgba(255,255,255,0.1)')}
|
||||||
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
@@ -373,24 +612,22 @@ function DownloadButton({
|
|||||||
className="absolute left-0 right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20"
|
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)' }}
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
>
|
>
|
||||||
{zipFiles.map((zipPath) => {
|
{gameFiles.map((file) => (
|
||||||
const name = zipPath.split('/').pop() ?? zipPath
|
<a
|
||||||
return (
|
key={file.path}
|
||||||
<a
|
href={downloadUrl(file.path)}
|
||||||
key={zipPath}
|
download
|
||||||
href={downloadUrl(zipPath)}
|
onClick={close}
|
||||||
download
|
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
|
||||||
onClick={close}
|
style={{ color: 'var(--text-primary)' }}
|
||||||
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
style={{ color: 'var(--text-primary)' }}
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
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)' }} className="flex-shrink-0">↓</span>
|
||||||
>
|
<PlatformPill platform={file.platform} />
|
||||||
<span style={{ color: 'var(--text-secondary)' }}>↓</span>
|
<span className="truncate">{file.filename}</span>
|
||||||
{name}
|
</a>
|
||||||
</a>
|
))}
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,38 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
import type { Game, GameSeries } from '@/types'
|
import type { Game, GamePlatform, GameSeries } from '@/types'
|
||||||
import GameDetailModal from './GameDetailModal'
|
import GameDetailModal from './GameDetailModal'
|
||||||
import FilterPanel from '@/components/FilterPanel'
|
import FilterPanel from '@/components/FilterPanel'
|
||||||
|
|
||||||
|
const PLATFORM_LABELS: Record<GamePlatform, string> = {
|
||||||
|
windows: 'WIN',
|
||||||
|
linux: 'LIN',
|
||||||
|
macos: 'MAC',
|
||||||
|
}
|
||||||
|
const PLATFORM_COLORS: Record<GamePlatform, string> = {
|
||||||
|
windows: '#0078d4',
|
||||||
|
linux: '#e95420',
|
||||||
|
macos: '#6e6e73',
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlatformBadges({ platforms }: { platforms: GamePlatform[] }) {
|
||||||
|
if (platforms.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{platforms.map((p) => (
|
||||||
|
<span
|
||||||
|
key={p}
|
||||||
|
className="px-1.5 py-0.5 rounded text-xs font-bold leading-none"
|
||||||
|
style={{ backgroundColor: PLATFORM_COLORS[p], color: '#fff' }}
|
||||||
|
>
|
||||||
|
{PLATFORM_LABELS[p]}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
libraryId: string
|
libraryId: string
|
||||||
}
|
}
|
||||||
@@ -218,6 +246,11 @@ function GameCard({ game, onClick }: { game: Game; onClick: () => void }) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-4xl">🎮</div>
|
<div className="absolute inset-0 flex items-center justify-center text-4xl">🎮</div>
|
||||||
)}
|
)}
|
||||||
|
{game.platforms.length > 0 && (
|
||||||
|
<div className="absolute bottom-1.5 left-1.5 flex gap-1">
|
||||||
|
<PlatformBadges platforms={game.platforms} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<p className="text-xs font-medium truncate leading-tight" style={{ color: 'var(--text-primary)' }} title={game.title}>
|
<p className="text-xs font-medium truncate leading-tight" style={{ color: 'var(--text-primary)' }} title={game.title}>
|
||||||
@@ -229,6 +262,11 @@ function GameCard({ game, onClick }: { game: Game; onClick: () => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SeriesCard({ series, onClick }: { series: GameSeries; onClick: () => void }) {
|
function SeriesCard({ series, onClick }: { series: GameSeries; onClick: () => void }) {
|
||||||
|
// Compute union of platforms across all games in the series
|
||||||
|
const seriesPlatforms: GamePlatform[] = [
|
||||||
|
...new Set(series.games.flatMap((g) => g.platforms)),
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -250,7 +288,13 @@ function SeriesCard({ series, onClick }: { series: GameSeries; onClick: () => vo
|
|||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-4xl">🎮</div>
|
<div className="absolute inset-0 flex items-center justify-center text-4xl">🎮</div>
|
||||||
)}
|
)}
|
||||||
{/* Game count badge */}
|
{/* Platform badges (bottom-left) */}
|
||||||
|
{seriesPlatforms.length > 0 && (
|
||||||
|
<div className="absolute bottom-1.5 left-1.5 flex gap-1">
|
||||||
|
<PlatformBadges platforms={seriesPlatforms} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Game count badge (bottom-right) */}
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-1.5 right-1.5 px-1.5 py-0.5 rounded text-xs font-semibold"
|
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' }}
|
style={{ backgroundColor: 'rgba(0,0,0,0.7)', color: '#fff' }}
|
||||||
|
|||||||
123
src/lib/games.ts
123
src/lib/games.ts
@@ -1,16 +1,36 @@
|
|||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import type { Game, GameSeries } from '@/types'
|
import type { Game, GameFile, GamePlatform, GameSeries } from '@/types'
|
||||||
import { getDb } from './db'
|
import { getDb } from './db'
|
||||||
import { HIDDEN_FILES, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
|
import { HIDDEN_FILES, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the platform for a given filename, or null if not a known game archive.
|
||||||
|
*/
|
||||||
|
function platformForFile(name: string): GamePlatform | null {
|
||||||
|
const lower = name.toLowerCase()
|
||||||
|
if (lower.endsWith('.zip')) return 'windows'
|
||||||
|
if (lower.endsWith('.tar.gz')) return 'linux'
|
||||||
|
if (lower.endsWith('.dmg')) return 'macos'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the Dirent is a game archive file or .app bundle directory.
|
||||||
|
*/
|
||||||
|
function isGameArchiveEntry(entry: fs.Dirent): boolean {
|
||||||
|
if (entry.isFile()) return platformForFile(entry.name) !== null
|
||||||
|
if (entry.isDirectory()) return entry.name.toLowerCase().endsWith('.app')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to build a Game from a directory.
|
* Attempts to build a Game from a directory.
|
||||||
* @param absPath Absolute path to the game directory.
|
* @param absPath Absolute path to the game directory.
|
||||||
* @param dirName The directory's own name (used as title).
|
* @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 relPath Path relative to the library root (used for IDs and file URLs).
|
||||||
* @param libraryId Library identifier.
|
* @param libraryId Library identifier.
|
||||||
* @returns Game, or null if the directory contains no .zip file.
|
* @returns Game, or null if the directory contains no known game files.
|
||||||
*/
|
*/
|
||||||
function buildGame(
|
function buildGame(
|
||||||
absPath: string,
|
absPath: string,
|
||||||
@@ -18,17 +38,41 @@ function buildGame(
|
|||||||
relPath: string,
|
relPath: string,
|
||||||
libraryId: string
|
libraryId: string
|
||||||
): Game | null {
|
): Game | null {
|
||||||
let allFiles: string[]
|
let entries: fs.Dirent[]
|
||||||
try {
|
try {
|
||||||
allFiles = fs.readdirSync(absPath)
|
entries = fs.readdirSync(absPath, { withFileTypes: true })
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const zipFiles = allFiles
|
const gameFiles: GameFile[] = []
|
||||||
.filter((f) => f.toLowerCase().endsWith('.zip'))
|
|
||||||
.sort((a, b) => a.localeCompare(b))
|
for (const entry of entries) {
|
||||||
if (zipFiles.length === 0) return null
|
if (HIDDEN_FILES.test(entry.name)) continue
|
||||||
|
if (entry.isFile()) {
|
||||||
|
const platform = platformForFile(entry.name)
|
||||||
|
if (platform) {
|
||||||
|
gameFiles.push({
|
||||||
|
path: path.join(relPath, entry.name),
|
||||||
|
platform,
|
||||||
|
filename: entry.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (entry.isDirectory() && entry.name.toLowerCase().endsWith('.app')) {
|
||||||
|
gameFiles.push({
|
||||||
|
path: path.join(relPath, entry.name),
|
||||||
|
platform: 'macos',
|
||||||
|
filename: entry.name,
|
||||||
|
isAppBundle: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameFiles.length === 0) return null
|
||||||
|
|
||||||
|
gameFiles.sort((a, b) => a.filename.localeCompare(b.filename))
|
||||||
|
|
||||||
|
const platforms: GamePlatform[] = [...new Set(gameFiles.map((f) => f.platform))]
|
||||||
|
|
||||||
const coverFile = findFile(absPath, /^cover$/i)
|
const coverFile = findFile(absPath, /^cover$/i)
|
||||||
const wideCoverFile = findFile(absPath, /^widecover$/i)
|
const wideCoverFile = findFile(absPath, /^widecover$/i)
|
||||||
@@ -42,58 +86,54 @@ function buildGame(
|
|||||||
wideCoverUrl: wideCoverFile
|
wideCoverUrl: wideCoverFile
|
||||||
? fileApiUrl(libraryId, path.join(relPath, wideCoverFile))
|
? fileApiUrl(libraryId, path.join(relPath, wideCoverFile))
|
||||||
: null,
|
: null,
|
||||||
zipFiles: zipFiles.map((f) => path.join(relPath, f)),
|
gameFiles,
|
||||||
|
platforms,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scanGamesLibrary(libraryRoot: string, libraryId: string): (Game | GameSeries)[] {
|
export function scanGamesLibrary(libraryRoot: string, libraryId: string): (Game | GameSeries)[] {
|
||||||
let topDirs: string[]
|
let topEntries: fs.Dirent[]
|
||||||
try {
|
try {
|
||||||
topDirs = fs
|
topEntries = 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)
|
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const results: (Game | GameSeries)[] = []
|
const results: (Game | GameSeries)[] = []
|
||||||
|
|
||||||
for (const dirName of topDirs) {
|
for (const topEntry of topEntries) {
|
||||||
|
const dirName = topEntry.name
|
||||||
const absPath = path.join(libraryRoot, dirName)
|
const absPath = path.join(libraryRoot, dirName)
|
||||||
|
|
||||||
let allFiles: string[]
|
let entries: fs.Dirent[]
|
||||||
try {
|
try {
|
||||||
allFiles = fs.readdirSync(absPath)
|
entries = fs.readdirSync(absPath, { withFileTypes: true })
|
||||||
} catch {
|
} catch {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standalone game: directory directly contains a .zip
|
// Standalone game: directory directly contains a game archive or .app bundle
|
||||||
const hasZip = allFiles.some((f) => f.toLowerCase().endsWith('.zip'))
|
const hasGameFiles = entries.some((e) => isGameArchiveEntry(e))
|
||||||
if (hasZip) {
|
if (hasGameFiles) {
|
||||||
const game = buildGame(absPath, dirName, dirName, libraryId)
|
const game = buildGame(absPath, dirName, dirName, libraryId)
|
||||||
if (game) results.push(game)
|
if (game) results.push(game)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// No .zip here — check subdirectories (series detection)
|
// No game files here — check subdirectories (series detection).
|
||||||
let subDirs: string[]
|
// Exclude .app-suffixed directories from series candidates — those belong to the parent game.
|
||||||
try {
|
const subDirs = entries.filter(
|
||||||
subDirs = fs
|
(e) => e.isDirectory() && !HIDDEN_FILES.test(e.name) && !e.name.toLowerCase().endsWith('.app')
|
||||||
.readdirSync(absPath, { withFileTypes: true })
|
)
|
||||||
.filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name))
|
|
||||||
.map((d) => d.name)
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const seriesGames: Game[] = []
|
const seriesGames: Game[] = []
|
||||||
for (const subDir of subDirs) {
|
for (const subDir of subDirs) {
|
||||||
const game = buildGame(
|
const game = buildGame(
|
||||||
path.join(absPath, subDir),
|
path.join(absPath, subDir.name),
|
||||||
subDir,
|
subDir.name,
|
||||||
path.join(dirName, subDir),
|
path.join(dirName, subDir.name),
|
||||||
libraryId
|
libraryId
|
||||||
)
|
)
|
||||||
if (game) seriesGames.push(game)
|
if (game) seriesGames.push(game)
|
||||||
@@ -161,6 +201,24 @@ export function gamesFromDb(libraryId: string): (Game | GameSeries)[] {
|
|||||||
for (const row of allRows) {
|
for (const row of allRows) {
|
||||||
if (row.item_type !== 'game') continue
|
if (row.item_type !== 'game') continue
|
||||||
const meta = row.metadata ? JSON.parse(row.metadata) : {}
|
const meta = row.metadata ? JSON.parse(row.metadata) : {}
|
||||||
|
|
||||||
|
// Build gameFiles with backward-compat for old zipFiles format
|
||||||
|
let gameFiles: GameFile[]
|
||||||
|
if (meta.gameFiles) {
|
||||||
|
gameFiles = meta.gameFiles
|
||||||
|
} else if (meta.zipFiles) {
|
||||||
|
// Legacy: map old zipFiles to GameFile with platform 'windows'
|
||||||
|
gameFiles = (meta.zipFiles as string[]).map((p: string) => ({
|
||||||
|
path: p,
|
||||||
|
platform: 'windows' as GamePlatform,
|
||||||
|
filename: p.split('/').pop() ?? p,
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
gameFiles = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const platforms: GamePlatform[] = [...new Set(gameFiles.map((f) => f.platform))]
|
||||||
|
|
||||||
const idPart = row.item_key.split(':game:')[1] ?? row.item_key
|
const idPart = row.item_key.split(':game:')[1] ?? row.item_key
|
||||||
const game: Game = {
|
const game: Game = {
|
||||||
id: idPart,
|
id: idPart,
|
||||||
@@ -168,7 +226,8 @@ export function gamesFromDb(libraryId: string): (Game | GameSeries)[] {
|
|||||||
title: row.title ?? decodeURIComponent(idPart),
|
title: row.title ?? decodeURIComponent(idPart),
|
||||||
coverUrl: meta.coverUrl ?? null,
|
coverUrl: meta.coverUrl ?? null,
|
||||||
wideCoverUrl: meta.wideCoverUrl ?? null,
|
wideCoverUrl: meta.wideCoverUrl ?? null,
|
||||||
zipFiles: meta.zipFiles ?? [],
|
gameFiles,
|
||||||
|
platforms,
|
||||||
}
|
}
|
||||||
if (row.parent_key && seriesMap.has(row.parent_key)) {
|
if (row.parent_key && seriesMap.has(row.parent_key)) {
|
||||||
seriesMap.get(row.parent_key)!.games.push(game)
|
seriesMap.get(row.parent_key)!.games.push(game)
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
|
|||||||
parent_key: seriesKey,
|
parent_key: seriesKey,
|
||||||
title: game.title,
|
title: game.title,
|
||||||
metadata: JSON.stringify({
|
metadata: JSON.stringify({
|
||||||
zipFiles: game.zipFiles,
|
gameFiles: game.gameFiles,
|
||||||
coverUrl: game.coverUrl,
|
coverUrl: game.coverUrl,
|
||||||
wideCoverUrl: game.wideCoverUrl,
|
wideCoverUrl: game.wideCoverUrl,
|
||||||
}),
|
}),
|
||||||
@@ -414,7 +414,7 @@ async function scanGames(library: Library, libraryRoot: string): Promise<void> {
|
|||||||
item_type: 'game',
|
item_type: 'game',
|
||||||
title: game.title,
|
title: game.title,
|
||||||
metadata: JSON.stringify({
|
metadata: JSON.stringify({
|
||||||
zipFiles: game.zipFiles,
|
gameFiles: game.gameFiles,
|
||||||
coverUrl: game.coverUrl,
|
coverUrl: game.coverUrl,
|
||||||
wideCoverUrl: game.wideCoverUrl,
|
wideCoverUrl: game.wideCoverUrl,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -8,13 +8,23 @@ export interface Library {
|
|||||||
coverExt: string | null
|
coverExt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GamePlatform = 'windows' | 'linux' | 'macos'
|
||||||
|
|
||||||
|
export interface GameFile {
|
||||||
|
path: string
|
||||||
|
platform: GamePlatform
|
||||||
|
filename: string
|
||||||
|
isAppBundle?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface Game {
|
export interface Game {
|
||||||
id: string
|
id: string
|
||||||
item_key?: string
|
item_key?: string
|
||||||
title: string
|
title: string
|
||||||
coverUrl: string | null
|
coverUrl: string | null
|
||||||
wideCoverUrl: string | null
|
wideCoverUrl: string | null
|
||||||
zipFiles: string[]
|
gameFiles: GameFile[]
|
||||||
|
platforms: GamePlatform[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameSeries {
|
export interface GameSeries {
|
||||||
|
|||||||
Reference in New Issue
Block a user