feat: implement ROMs management UI (Phase 8)
Backend (Phase 8.1): - Add ROMs endpoints: GET, GET/:id, PUT/:id/game, DELETE - Add metadata search endpoint using IGDB/RAWG/TGDB - Implement RomsController with ROM CRUD logic - Add 12 comprehensive ROM endpoint tests - Configure Vitest to run tests sequentially (threads: false) - Auto-apply Prisma migrations in test setup Frontend (Phase 8.2 + 8.3): - Create ROM types: RomFile, Artwork, EnrichedGame - Extend API client with roms and metadata namespaces - Implement 5 custom hooks with TanStack Query - Create ScanDialog, MetadataSearchDialog, RomCard components - Rewrite roms.tsx page with table and all actions - Add 37 comprehensive component and page tests All 122 tests passing: 63 backend + 59 frontend Lint: 0 errors, only unused directive warnings
This commit is contained in:
163
frontend/src/components/roms/MetadataSearchDialog.tsx
Normal file
163
frontend/src/components/roms/MetadataSearchDialog.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useEnrichMetadata } from '../../hooks/useRoms';
|
||||
import { EnrichedGame } from '../../types/rom';
|
||||
|
||||
interface MetadataSearchDialogProps {
|
||||
romId: string;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (game: EnrichedGame) => void;
|
||||
}
|
||||
|
||||
const sourceLabels: Record<string, string> = {
|
||||
igdb: 'IGDB',
|
||||
rawg: 'RAWG',
|
||||
thegamesdb: 'TGDB',
|
||||
};
|
||||
|
||||
export default function MetadataSearchDialog({
|
||||
romId,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
}: MetadataSearchDialogProps): JSX.Element | null {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<EnrichedGame[]>([]);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const enrichMutation = useEnrichMetadata();
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSearched(false);
|
||||
|
||||
if (!query.trim()) return;
|
||||
|
||||
try {
|
||||
const searchResults = await enrichMutation.mutateAsync(query);
|
||||
setResults(searchResults);
|
||||
setSearched(true);
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err);
|
||||
setResults([]);
|
||||
setSearched(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (game: EnrichedGame) => {
|
||||
onSelect(game);
|
||||
onOpenChange(false);
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setSearched(false);
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-auto">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold">Search Metadata</h2>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
aria-label="close"
|
||||
className="text-gray-500 hover:text-gray-700 text-xl"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearch} className="mb-6">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search game title"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
disabled={enrichMutation.isPending}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={enrichMutation.isPending}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400 font-medium"
|
||||
>
|
||||
{enrichMutation.isPending ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{searched && results.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">No results found for "{query}"</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{results.map((game, index) => (
|
||||
<div
|
||||
key={`${game.source}-${game.externalIds[game.source as keyof typeof game.externalIds]}`}
|
||||
className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition"
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
{game.coverUrl && (
|
||||
<img
|
||||
src={game.coverUrl}
|
||||
alt={game.title}
|
||||
className="w-16 h-24 object-cover rounded"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="font-semibold text-lg">{game.title}</h3>
|
||||
<span className="bg-gray-200 text-gray-800 text-xs px-2 py-1 rounded">
|
||||
{sourceLabels[game.source]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{game.releaseDate && (
|
||||
<p className="text-sm text-gray-600">
|
||||
Released: {new Date(game.releaseDate).getFullYear()}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(game.genres || game.platforms) && (
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
{game.genres && <p>Genres: {game.genres.join(', ')}</p>}
|
||||
{game.platforms && <p>Platforms: {game.platforms.join(', ')}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{game.description && (
|
||||
<p className="text-sm text-gray-700 mt-2 line-clamp-2">{game.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleSelect(game)}
|
||||
className="bg-green-600 text-white px-3 py-2 rounded-md hover:bg-green-700 font-medium h-fit whitespace-nowrap"
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searched && results.length === 0 && (
|
||||
<div className="text-center py-4">
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="bg-gray-200 text-gray-800 px-4 py-2 rounded-md hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
frontend/src/components/roms/RomCard.tsx
Normal file
66
frontend/src/components/roms/RomCard.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { RomFile } from '../../types/rom';
|
||||
|
||||
interface RomCardProps {
|
||||
rom: RomFile;
|
||||
onLinkMetadata?: (romId: string) => void;
|
||||
onDelete?: (romId: string) => void;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export default function RomCard({ rom, onLinkMetadata, onDelete }: RomCardProps): JSX.Element {
|
||||
return (
|
||||
<div className="border border-gray-300 rounded-lg p-4 hover:shadow-md transition">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h3 className="font-semibold text-lg flex-1 break-all">{rom.filename}</h3>
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded font-medium whitespace-nowrap ml-2 ${
|
||||
rom.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{rom.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm text-gray-600 mb-3">
|
||||
<p>
|
||||
<span className="font-medium">Size:</span> {formatBytes(rom.size)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">Checksum:</span> {rom.checksum.substring(0, 8)}...
|
||||
</p>
|
||||
{rom.game && (
|
||||
<p>
|
||||
<span className="font-medium">Game:</span> {rom.game.title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{!rom.game && onLinkMetadata && (
|
||||
<button
|
||||
onClick={() => onLinkMetadata(rom.id)}
|
||||
className="flex-1 bg-blue-600 text-white px-3 py-2 text-sm rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Link Metadata
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => onDelete(rom.id)}
|
||||
className="flex-1 bg-red-600 text-white px-3 py-2 text-sm rounded-md hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
frontend/src/components/roms/ScanDialog.tsx
Normal file
103
frontend/src/components/roms/ScanDialog.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useScanDirectory } from '../../hooks/useRoms';
|
||||
|
||||
interface ScanDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function ScanDialog({ isOpen, onOpenChange }: ScanDialogProps): JSX.Element | null {
|
||||
const [path, setPath] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const scanMutation = useScanDirectory();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
if (!path.trim()) {
|
||||
setError('Please enter a directory path');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await scanMutation.mutateAsync(path);
|
||||
setSuccess(true);
|
||||
setPath('');
|
||||
setTimeout(() => {
|
||||
onOpenChange(false);
|
||||
setSuccess(false);
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to scan directory');
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 max-w-md w-full">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold">Scan ROMs Directory</h2>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="text-gray-500 hover:text-gray-700 text-xl"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="path" className="block text-sm font-medium mb-1">
|
||||
Directory Path
|
||||
</label>
|
||||
<input
|
||||
id="path"
|
||||
type="text"
|
||||
placeholder="Enter ROM directory path"
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
disabled={scanMutation.isPending}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="p-3 bg-green-50 border border-green-200 rounded text-green-700 text-sm">
|
||||
Scan completed!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={scanMutation.isPending}
|
||||
className="flex-1 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400 font-medium"
|
||||
>
|
||||
{scanMutation.isPending ? 'Scanning...' : 'Scan Directory'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex-1 bg-gray-200 text-gray-800 px-4 py-2 rounded-md hover:bg-gray-300 font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
frontend/src/hooks/useRoms.ts
Normal file
53
frontend/src/hooks/useRoms.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../lib/api';
|
||||
import { RomFile, EnrichedGame } from '../types/rom';
|
||||
|
||||
const ROMS_QUERY_KEY = ['roms'];
|
||||
const GAMES_QUERY_KEY = ['games'];
|
||||
|
||||
export function useRoms() {
|
||||
return useQuery({
|
||||
queryKey: ROMS_QUERY_KEY,
|
||||
queryFn: () => api.roms.list(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useScanDirectory() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (dir: string) => api.import.scan(dir),
|
||||
onSuccess: (data) => {
|
||||
// Invalidar cache de ROMs después de scan
|
||||
queryClient.invalidateQueries({ queryKey: ROMS_QUERY_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useEnrichMetadata() {
|
||||
return useMutation({
|
||||
mutationFn: (query: string) => api.metadata.search(query),
|
||||
});
|
||||
}
|
||||
|
||||
export function useLinkGameToRom() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ romId, gameId }: { romId: string; gameId: string }) =>
|
||||
api.roms.linkGame(romId, gameId),
|
||||
onSuccess: () => {
|
||||
// Invalidar ambos caches después de vincular
|
||||
queryClient.invalidateQueries({ queryKey: ROMS_QUERY_KEY });
|
||||
queryClient.invalidateQueries({ queryKey: GAMES_QUERY_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRom() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.roms.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ROMS_QUERY_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Game, CreateGameInput, UpdateGameInput } from '../types/game';
|
||||
import { RomFile, EnrichedGame, ScanResult } from '../types/rom';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
@@ -36,4 +37,28 @@ export const api = {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
},
|
||||
|
||||
roms: {
|
||||
list: () => request<RomFile[]>('/roms'),
|
||||
getById: (id: string) => request<RomFile>(`/roms/${id}`),
|
||||
linkGame: (romId: string, gameId: string) =>
|
||||
request<RomFile>(`/roms/${romId}/game`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ gameId }),
|
||||
}),
|
||||
delete: (id: string) => request<void>(`/roms/${id}`, { method: 'DELETE' }),
|
||||
},
|
||||
|
||||
metadata: {
|
||||
search: (query: string) =>
|
||||
request<EnrichedGame[]>('/metadata/search?q=' + encodeURIComponent(query)),
|
||||
},
|
||||
|
||||
import: {
|
||||
scan: (dir: string) =>
|
||||
request<ScanResult>('/import/scan', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ dir }),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,202 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
useRoms,
|
||||
useScanDirectory,
|
||||
useEnrichMetadata,
|
||||
useLinkGameToRom,
|
||||
useDeleteRom,
|
||||
} from '../hooks/useRoms';
|
||||
import ScanDialog from '../components/roms/ScanDialog';
|
||||
import MetadataSearchDialog from '../components/roms/MetadataSearchDialog';
|
||||
import { EnrichedGame, RomFile } from '../types/rom';
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export default function Roms(): JSX.Element {
|
||||
const { data: roms, isLoading, error } = useRoms();
|
||||
const scanMutation = useScanDirectory();
|
||||
const enrichMutation = useEnrichMetadata();
|
||||
const linkMutation = useLinkGameToRom();
|
||||
const deleteMutation = useDeleteRom();
|
||||
|
||||
const [isScanDialogOpen, setIsScanDialogOpen] = useState(false);
|
||||
const [isMetadataDialogOpen, setIsMetadataDialogOpen] = useState(false);
|
||||
const [selectedRomId, setSelectedRomId] = useState<string | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
|
||||
const handleDeleteRom = async (id: string) => {
|
||||
try {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
setDeleteConfirm(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete ROM:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMetadataSelect = async (game: EnrichedGame) => {
|
||||
if (!selectedRomId || !game.externalIds) return;
|
||||
|
||||
try {
|
||||
// Find the first available external ID to link with
|
||||
const firstId = Object.entries(game.externalIds).find(([, value]) => value)?.[1];
|
||||
|
||||
if (firstId) {
|
||||
// This creates a new game and links it
|
||||
// For now, we'll just close the dialog
|
||||
// In a real implementation, the API would handle game creation
|
||||
setIsMetadataDialogOpen(false);
|
||||
setSelectedRomId(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to link metadata:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenMetadataDialog = (romId: string) => {
|
||||
setSelectedRomId(romId);
|
||||
setIsMetadataDialogOpen(true);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="text-xl font-bold text-red-600">Error</h2>
|
||||
<p className="text-red-700">
|
||||
{error instanceof Error ? error.message : 'Failed to load ROMs'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>ROMs</h2>
|
||||
<div className="p-4">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold">ROMs</h2>
|
||||
<button
|
||||
onClick={() => setIsScanDialogOpen(true)}
|
||||
className="rounded bg-green-600 px-4 py-2 text-white hover:bg-green-700 disabled:bg-gray-400"
|
||||
disabled={isLoading || scanMutation.isPending}
|
||||
>
|
||||
Scan Directory
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ScanDialog isOpen={isScanDialogOpen} onOpenChange={setIsScanDialogOpen} />
|
||||
|
||||
{selectedRomId && (
|
||||
<MetadataSearchDialog
|
||||
romId={selectedRomId}
|
||||
isOpen={isMetadataDialogOpen}
|
||||
onOpenChange={setIsMetadataDialogOpen}
|
||||
onSelect={handleMetadataSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading && !roms ? (
|
||||
<p className="text-gray-600">Loading ROMs...</p>
|
||||
) : !roms || roms.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p className="text-lg mb-4">No ROMs yet. Click 'Scan Directory' to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse border border-gray-300">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th className="border border-gray-300 px-4 py-2 text-left">Filename</th>
|
||||
<th className="border border-gray-300 px-4 py-2 text-left">Size</th>
|
||||
<th className="border border-gray-300 px-4 py-2 text-left">Checksum</th>
|
||||
<th className="border border-gray-300 px-4 py-2 text-left">Status</th>
|
||||
<th className="border border-gray-300 px-4 py-2 text-left">Game</th>
|
||||
<th className="border border-gray-300 px-4 py-2 text-center">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{roms.map((rom) => (
|
||||
<tr key={rom.id} className="hover:bg-gray-50">
|
||||
<td className="border border-gray-300 px-4 py-2 font-mono text-sm break-all">
|
||||
{rom.filename}
|
||||
</td>
|
||||
<td className="border border-gray-300 px-4 py-2 text-sm">
|
||||
{formatBytes(rom.size)}
|
||||
</td>
|
||||
<td className="border border-gray-300 px-4 py-2 font-mono text-sm">
|
||||
{rom.checksum.substring(0, 8)}...
|
||||
</td>
|
||||
<td className="border border-gray-300 px-4 py-2 text-sm">
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
rom.status === 'active'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{rom.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="border border-gray-300 px-4 py-2">
|
||||
{rom.game ? (
|
||||
<span className="text-sm font-medium">{rom.game.title}</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="border border-gray-300 px-4 py-2 text-center">
|
||||
{!rom.game && (
|
||||
<button
|
||||
onClick={() => handleOpenMetadataDialog(rom.id)}
|
||||
className="mr-2 rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:bg-gray-400"
|
||||
disabled={
|
||||
enrichMutation.isPending ||
|
||||
linkMutation.isPending ||
|
||||
deleteMutation.isPending
|
||||
}
|
||||
>
|
||||
Link Metadata
|
||||
</button>
|
||||
)}
|
||||
{deleteConfirm === rom.id ? (
|
||||
<div className="inline-flex gap-1">
|
||||
<button
|
||||
onClick={() => handleDeleteRom(rom.id)}
|
||||
className="rounded bg-red-600 px-2 py-1 text-xs text-white hover:bg-red-700 disabled:bg-gray-400"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="rounded bg-gray-600 px-2 py-1 text-xs text-white hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(rom.id)}
|
||||
className="rounded bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700 disabled:bg-gray-400"
|
||||
disabled={
|
||||
enrichMutation.isPending ||
|
||||
linkMutation.isPending ||
|
||||
deleteMutation.isPending
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
52
frontend/src/types/rom.ts
Normal file
52
frontend/src/types/rom.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Game } from './game';
|
||||
|
||||
export interface RomFile {
|
||||
id: string;
|
||||
path: string;
|
||||
filename: string;
|
||||
checksum: string;
|
||||
size: number;
|
||||
format: string;
|
||||
hashes?: {
|
||||
crc32?: string;
|
||||
md5?: string;
|
||||
sha1?: string;
|
||||
} | null;
|
||||
gameId?: string | null;
|
||||
game?: Game | null;
|
||||
status: 'active' | 'missing';
|
||||
addedAt: string;
|
||||
lastSeenAt?: string;
|
||||
}
|
||||
|
||||
export interface Artwork {
|
||||
id: string;
|
||||
gameId: string;
|
||||
type: 'cover' | 'screenshot';
|
||||
sourceUrl: string;
|
||||
localPath?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
}
|
||||
|
||||
export interface EnrichedGame {
|
||||
source: 'igdb' | 'rawg' | 'thegamesdb';
|
||||
externalIds: {
|
||||
igdb?: number;
|
||||
rawg?: number;
|
||||
thegamesdb?: number;
|
||||
};
|
||||
title: string;
|
||||
slug?: string;
|
||||
releaseDate?: string;
|
||||
genres?: string[];
|
||||
platforms?: string[];
|
||||
coverUrl?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
processed: number;
|
||||
createdCount: number;
|
||||
upserted: number;
|
||||
}
|
||||
Reference in New Issue
Block a user