feat: add UI components for alert dialog, badge, checkbox, dialog, label, select, sheet, table, textarea
Some checks failed
CI / lint (push) Failing after 1m5s
CI / test-backend (push) Has been skipped
CI / test-frontend (push) Has been skipped
CI / test-e2e (push) Has been skipped

- Implemented AlertDialog component with overlay, content, header, footer, title, description, action, and cancel functionalities.
- Created Badge component with variant support for different styles.
- Developed Checkbox component with custom styling and indicator.
- Added Dialog component with trigger, close, overlay, content, header, footer, title, and description.
- Introduced Label component for form elements.
- Built Select component with trigger, content, group, item, label, separator, and scroll buttons.
- Created Sheet component with trigger, close, overlay, content, header, footer, title, and description.
- Implemented Table component with header, body, footer, row, head, cell, and caption.
- Added Textarea component with custom styling.
- Established API service for game management with CRUD operations and metadata search functionalities.
- Updated dependencies in package lock files.
This commit is contained in:
2026-03-18 19:21:36 +01:00
parent b92cc19137
commit a07096d7c7
95 changed files with 8176 additions and 615 deletions

View File

@@ -0,0 +1,35 @@
# frontend-design
A frontend design skill for [Claude Code](https://claude.com/claude-code) that builds **hot, sleek, sexy, usable, fun, and addictive** interfaces.
Dark-first themes, terminal-inspired typography, neon accents, tactile micro-interactions, and visual discovery patterns — distilled from real projects into a reusable skill.
## Install
```bash
npx skills add mager/frontend-design
```
## What it does
- **Generate new UI** — Describe what you want and get components/pages in this style
- **Restyle existing code** — Point it at your frontend and it'll apply the aesthetic
- **Design guidance** — Ask for feedback and get opinionated direction
## The aesthetic
- Dark mode by default (rich blacks, not gray)
- JetBrains Mono + Space Grotesk typography
- Neon accent colors — cyan, purple, lime, gold, coral
- Tactile hover interactions (lifts, glows, border transitions)
- Visual discovery layouts (masonry walls, bento grids, dense scrollable content)
- CSS custom properties for contextual theming
- Mobile-first, always
## Tech stack
Adapts to whatever framework you're using. When starting fresh, prefers Astro, SvelteKit, or Next.js with custom CSS. Never suggests Bootstrap.
## License
MIT

View File

@@ -0,0 +1,84 @@
---
name: frontend-design
description: Frontend design skill that generates, restyles, and guides UI development in mager's signature aesthetic — hot, sleek, sexy, usable, fun, and addictive interfaces with dark-first themes, terminal-inspired typography, neon accents, and visual discovery patterns. Use this skill when building or reviewing any frontend UI.
---
# frontend-design
You are a frontend design agent channeling a specific aesthetic philosophy. Every UI you touch should feel **hot, sleek, sexy, usable, fun, and addictive**. You create interfaces people want to keep scrolling, clicking, and exploring.
## Core Philosophy
**Visual discovery is king.** The best UI always has something new to look at, scroll through, or explore. Think beatbrain's album art wall, think infinite scrollable content that rewards curiosity. Users should feel pulled deeper into the experience.
**Dark mode is home.** Default to dark themes. Rich blacks (`#0a-#15` range), not washed-out grays. Light mode is acceptable when the project calls for it, but dark is the soul of the aesthetic.
**Typography is identity.** Monospace fonts (especially JetBrains Mono) communicate precision, craft, and developer culture. Pair with a geometric display face like Space Grotesk for headlines. Body text should be generous — large sizes, good line-height, proper reading widths (~100ch for prose). Use `clamp()` for responsive type scaling.
**Color is mood.** Neon accents against dark backgrounds — cyan, purple, lime green, gold/amber, coral. Use color to categorize and differentiate (blog categories, content types, status indicators). Build with CSS custom properties so color theming is contextual and swappable. Warm and cool accent pairings create sophisticated palettes.
**Interactions are tactile.** Every hover, click, and scroll should feel satisfying:
- Hover lifts: `translateY(-2px)` with subtle scale
- Color/border transitions: 0.15-0.3s ease
- Staggered animations for lists and grids
- Glow effects via text-shadow and box-shadow
- Image hover: scale + brightness shift to reveal overlays
**Speed is non-negotiable.** No jank, no layout shifts, no waiting. Everything should feel instant and fluid.
## Design Patterns to Suggest (Not Enforce)
These are signature patterns. Recommend them when they fit, but don't force them:
- **Cards with thick bottom borders** — colored by category, expanding on hover
- **Glassmorphic sticky navbars** — backdrop-blur, subtle transparency
- **Masonry/discovery walls** — dense grids of visual content with no gaps, hover overlays
- **Bento grid layouts** — asymmetric featured content areas
- **Category badges** — uppercase, letter-spaced, monospace, with accent colors
- **Gradient text** — on headlines for emphasis
- **Scanline/CRT overlays** — subtle texture for that terminal vibe
- **Floating mesh gradient backgrounds** — ambient depth
## Layout Principles
- Max-width containers: 1200px, centered
- Responsive grids: `repeat(auto-fit, minmax(280-350px, 1fr))`
- Mobile-first, always
- Generous padding that scales with viewport
- Sticky elements where they aid navigation
- Scroll-driven reveals and animations
## Tech Stack Guidance
Adapt to whatever framework the project uses, but when starting fresh or when asked:
- **Preferred:** Astro, SvelteKit, or Next.js
- **Styling:** Custom CSS with CSS custom properties preferred. Tailwind is fine when speed matters. DaisyUI is acceptable as a component base.
- **Fonts:** JetBrains Mono (mono), Space Grotesk (display), system sans-serif or Jost (body)
- **Never suggest:** Bootstrap or heavy opinionated UI frameworks that fight the aesthetic
## When Generating New UI
1. Start with the dark color foundation
2. Establish the type scale with `clamp()` responsive sizing
3. Define CSS custom properties for colors, spacing, and theming
4. Build components that invite interaction — every element should have a hover state
5. Add visual discovery patterns — grids, walls, carousels that reward exploration
6. Layer in micro-animations last — staggered fades, lifts, glows
## When Restyling Existing Code
1. Identify the current framework and work within it
2. Swap the color palette toward dark + neon accents
3. Upgrade typography to the monospace + geometric sans pairing
4. Add hover micro-interactions to all interactive elements
5. Improve visual density and discovery patterns where possible
6. Preserve existing functionality — only change the skin
## When Giving Design Guidance
- Speak in terms of feel: "hot", "sleek", "addictive", "satisfying"
- Reference concrete patterns from the user's existing projects
- Prioritize what makes the UI more explorable and tactile
- Push for visual density over whitespace — content should be rich and discoverable
- Always consider mobile experience — touch targets, scrolling, thumb zones

View File

@@ -0,0 +1,240 @@
---
name: shadcn
description: Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI. Provides project context, component docs, and usage examples. Applies when working with shadcn/ui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for "shadcn init", "create an app with --preset", or "switch to --preset".
user-invocable: false
---
# shadcn/ui
A framework for building ui, components and design systems. Components are added as source code to the user's project via the CLI.
> **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest` — based on the project's `packageManager`. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
## Current Project Context
```json
!`npx shadcn@latest info --json 2>/dev/null || echo '{"error": "No shadcn project found. Run shadcn init first."}'`
```
The JSON above contains the project config and installed components. Use `npx shadcn@latest docs <component>` to get documentation and example URLs for any component.
## Principles
1. **Use existing components first.** Use `npx shadcn@latest search` to check registries before writing custom UI. Check community registries too.
2. **Compose, don't reinvent.** Settings page = Tabs + Card + form controls. Dashboard = Sidebar + Card + Chart + Table.
3. **Use built-in variants before custom styles.** `variant="outline"`, `size="sm"`, etc.
4. **Use semantic colors.** `bg-primary`, `text-muted-foreground` — never raw values like `bg-blue-500`.
## Critical Rules
These rules are **always enforced**. Each links to a file with Incorrect/Correct code pairs.
### Styling & Tailwind → [styling.md](./rules/styling.md)
- **`className` for layout, not styling.** Never override component colors or typography.
- **No `space-x-*` or `space-y-*`.** Use `flex` with `gap-*`. For vertical stacks, `flex flex-col gap-*`.
- **Use `size-*` when width and height are equal.** `size-10` not `w-10 h-10`.
- **Use `truncate` shorthand.** Not `overflow-hidden text-ellipsis whitespace-nowrap`.
- **No manual `dark:` color overrides.** Use semantic tokens (`bg-background`, `text-muted-foreground`).
- **Use `cn()` for conditional classes.** Don't write manual template literal ternaries.
- **No manual `z-index` on overlay components.** Dialog, Sheet, Popover, etc. handle their own stacking.
### Forms & Inputs → [forms.md](./rules/forms.md)
- **Forms use `FieldGroup` + `Field`.** Never use raw `div` with `space-y-*` or `grid gap-*` for form layout.
- **`InputGroup` uses `InputGroupInput`/`InputGroupTextarea`.** Never raw `Input`/`Textarea` inside `InputGroup`.
- **Buttons inside inputs use `InputGroup` + `InputGroupAddon`.**
- **Option sets (27 choices) use `ToggleGroup`.** Don't loop `Button` with manual active state.
- **`FieldSet` + `FieldLegend` for grouping related checkboxes/radios.** Don't use a `div` with a heading.
- **Field validation uses `data-invalid` + `aria-invalid`.** `data-invalid` on `Field`, `aria-invalid` on the control. For disabled: `data-disabled` on `Field`, `disabled` on the control.
### Component Structure → [composition.md](./rules/composition.md)
- **Items always inside their Group.** `SelectItem``SelectGroup`. `DropdownMenuItem``DropdownMenuGroup`. `CommandItem``CommandGroup`.
- **Use `asChild` (radix) or `render` (base) for custom triggers.** Check `base` field from `npx shadcn@latest info`. → [base-vs-radix.md](./rules/base-vs-radix.md)
- **Dialog, Sheet, and Drawer always need a Title.** `DialogTitle`, `SheetTitle`, `DrawerTitle` required for accessibility. Use `className="sr-only"` if visually hidden.
- **Use full Card composition.** `CardHeader`/`CardTitle`/`CardDescription`/`CardContent`/`CardFooter`. Don't dump everything in `CardContent`.
- **Button has no `isPending`/`isLoading`.** Compose with `Spinner` + `data-icon` + `disabled`.
- **`TabsTrigger` must be inside `TabsList`.** Never render triggers directly in `Tabs`.
- **`Avatar` always needs `AvatarFallback`.** For when the image fails to load.
### Use Components, Not Custom Markup → [composition.md](./rules/composition.md)
- **Use existing components before custom markup.** Check if a component exists before writing a styled `div`.
- **Callouts use `Alert`.** Don't build custom styled divs.
- **Empty states use `Empty`.** Don't build custom empty state markup.
- **Toast via `sonner`.** Use `toast()` from `sonner`.
- **Use `Separator`** instead of `<hr>` or `<div className="border-t">`.
- **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs.
- **Use `Badge`** instead of custom styled spans.
### Icons → [icons.md](./rules/icons.md)
- **Icons in `Button` use `data-icon`.** `data-icon="inline-start"` or `data-icon="inline-end"` on the icon.
- **No sizing classes on icons inside components.** Components handle icon sizing via CSS. No `size-4` or `w-4 h-4`.
- **Pass icons as objects, not string keys.** `icon={CheckIcon}`, not a string lookup.
### CLI
- **Never decode or fetch preset codes manually.** Pass them directly to `npx shadcn@latest init --preset <code>`.
## Key Patterns
These are the most common patterns that differentiate correct shadcn/ui code. For edge cases, see the linked rule files above.
```tsx
// Form layout: FieldGroup + Field, not div + Label.
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" />
</Field>
</FieldGroup>
// Validation: data-invalid on Field, aria-invalid on the control.
<Field data-invalid>
<FieldLabel>Email</FieldLabel>
<Input aria-invalid />
<FieldDescription>Invalid email.</FieldDescription>
</Field>
// Icons in buttons: data-icon, no sizing classes.
<Button>
<SearchIcon data-icon="inline-start" />
Search
</Button>
// Spacing: gap-*, not space-y-*.
<div className="flex flex-col gap-4"> // correct
<div className="space-y-4"> // wrong
// Equal dimensions: size-*, not w-* h-*.
<Avatar className="size-10"> // correct
<Avatar className="w-10 h-10"> // wrong
// Status colors: Badge variants or semantic tokens, not raw colors.
<Badge variant="secondary">+20.1%</Badge> // correct
<span className="text-emerald-600">+20.1%</span> // wrong
```
## Component Selection
| Need | Use |
| -------------------------- | --------------------------------------------------------------------------------------------------- |
| Button/action | `Button` with appropriate variant |
| Form inputs | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` |
| Toggle between 25 options | `ToggleGroup` + `ToggleGroupItem` |
| Data display | `Table`, `Card`, `Badge`, `Avatar` |
| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` |
| Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) |
| Feedback | `sonner` (toast), `Alert`, `Progress`, `Skeleton`, `Spinner` |
| Command palette | `Command` inside `Dialog` |
| Charts | `Chart` (wraps Recharts) |
| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` |
| Empty states | `Empty` |
| Menus | `DropdownMenu`, `ContextMenu`, `Menubar` |
| Tooltips/info | `Tooltip`, `HoverCard`, `Popover` |
## Key Fields
The injected project context contains these key fields:
- **`aliases`** → use the actual alias prefix for imports (e.g. `@/`, `~/`), never hardcode.
- **`isRSC`** → when `true`, components using `useState`, `useEffect`, event handlers, or browser APIs need `"use client"` at the top of the file. Always reference this field when advising on the directive.
- **`tailwindVersion`** → `"v4"` uses `@theme inline` blocks; `"v3"` uses `tailwind.config.js`.
- **`tailwindCssFile`** → the global CSS file where custom CSS variables are defined. Always edit this file, never create a new one.
- **`style`** → component visual treatment (e.g. `nova`, `vega`).
- **`base`** → primitive library (`radix` or `base`). Affects component APIs and available props.
- **`iconLibrary`** → determines icon imports. Use `lucide-react` for `lucide`, `@tabler/icons-react` for `tabler`, etc. Never assume `lucide-react`.
- **`resolvedPaths`** → exact file-system destinations for components, utils, hooks, etc.
- **`framework`** → routing and file conventions (e.g. Next.js App Router vs Vite SPA).
- **`packageManager`** → use this for any non-shadcn dependency installs (e.g. `pnpm add date-fns` vs `npm install date-fns`).
See [cli.md — `info` command](./cli.md) for the full field reference.
## Component Docs, Examples, and Usage
Run `npx shadcn@latest docs <component>` to get the URLs for a component's documentation, examples, and API reference. Fetch these URLs to get the actual content.
```bash
npx shadcn@latest docs button dialog select
```
**When creating, fixing, debugging, or using a component, always run `npx shadcn@latest docs` and fetch the URLs first.** This ensures you're working with the correct API and usage patterns rather than guessing.
## Workflow
1. **Get project context** — already injected above. Run `npx shadcn@latest info` again if you need to refresh.
2. **Check installed components first** — before running `add`, always check the `components` list from project context or list the `resolvedPaths.ui` directory. Don't import components that haven't been added, and don't re-add ones already installed.
3. **Find components**`npx shadcn@latest search`.
4. **Get docs and examples** — run `npx shadcn@latest docs <component>` to get URLs, then fetch them. Use `npx shadcn@latest view` to browse registry items you haven't installed. To preview changes to installed components, use `npx shadcn@latest add --diff`.
5. **Install or update**`npx shadcn@latest add`. When updating existing components, use `--dry-run` and `--diff` to preview changes first (see [Updating Components](#updating-components) below).
6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project.
7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on.
8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, etc.), ask which registry to use. Never default to a registry on behalf of the user.
9. **Switching presets** — Ask the user first: **reinstall**, **merge**, or **skip**?
- **Reinstall**: `npx shadcn@latest init --preset <code> --force --reinstall`. Overwrites all components.
- **Merge**: `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually.
- **Skip**: `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS, leaves components as-is.
## Updating Components
When the user asks to update a component from upstream while keeping their local changes, use `--dry-run` and `--diff` to intelligently merge. **NEVER fetch raw files from GitHub manually — always use the CLI.**
1. Run `npx shadcn@latest add <component> --dry-run` to see all files that would be affected.
2. For each file, run `npx shadcn@latest add <component> --diff <file>` to see what changed upstream vs local.
3. Decide per file based on the diff:
- No local changes → safe to overwrite.
- Has local changes → read the local file, analyze the diff, and apply upstream updates while preserving local modifications.
- User says "just update everything" → use `--overwrite`, but confirm first.
4. **Never use `--overwrite` without the user's explicit approval.**
## Quick Reference
```bash
# Create a new project.
npx shadcn@latest init --name my-app --preset base-nova
npx shadcn@latest init --name my-app --preset a2r6bw --template vite
# Create a monorepo project.
npx shadcn@latest init --name my-app --preset base-nova --monorepo
npx shadcn@latest init --name my-app --preset base-nova --template next --monorepo
# Initialize existing project.
npx shadcn@latest init --preset base-nova
npx shadcn@latest init --defaults # shortcut: --template=next --preset=base-nova
# Add components.
npx shadcn@latest add button card dialog
npx shadcn@latest add @magicui/shimmer-button
npx shadcn@latest add --all
# Preview changes before adding/updating.
npx shadcn@latest add button --dry-run
npx shadcn@latest add button --diff button.tsx
npx shadcn@latest add @acme/form --view button.tsx
# Search registries.
npx shadcn@latest search @shadcn -q "sidebar"
npx shadcn@latest search @tailark -q "stats"
# Get component docs and example URLs.
npx shadcn@latest docs button dialog select
# View registry item details (for items not yet installed).
npx shadcn@latest view @shadcn/button
```
**Named presets:** `base-nova`, `radix-nova`
**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo)
**Preset codes:** Base62 strings starting with `a` (e.g. `a2r6bw`), from [ui.shadcn.com](https://ui.shadcn.com).
## Detailed References
- [rules/forms.md](./rules/forms.md) — FieldGroup, Field, InputGroup, ToggleGroup, FieldSet, validation states
- [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading
- [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icons as objects
- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, className, spacing, size, truncate, dark mode, cn(), z-index
- [rules/base-vs-radix.md](./rules/base-vs-radix.md) — asChild vs render, Select, ToggleGroup, Slider, Accordion
- [cli.md](./cli.md) — Commands, flags, presets, templates
- [customization.md](./customization.md) — Theming, CSS variables, extending components

View File

@@ -0,0 +1,5 @@
interface:
display_name: "shadcn/ui"
short_description: "Manages shadcn/ui components — adding, searching, fixing, debugging, styling, and composing UI."
icon_small: "./assets/shadcn-small.png"
icon_large: "./assets/shadcn.png"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,255 @@
# shadcn CLI Reference
Configuration is read from `components.json`.
> **IMPORTANT:** Always run commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest`. Check `packageManager` from project context to choose the right one. Examples below use `npx shadcn@latest` but substitute the correct runner for the project.
> **IMPORTANT:** Only use the flags documented below. Do not invent or guess flags — if a flag isn't listed here, it doesn't exist. The CLI auto-detects the package manager from the project's lockfile; there is no `--package-manager` flag.
## Contents
- Commands: init, add (dry-run, smart merge), search, view, docs, info, build
- Templates: next, vite, start, react-router, astro
- Presets: named, code, URL formats and fields
- Switching presets
---
## Commands
### `init` — Initialize or create a project
```bash
npx shadcn@latest init [components...] [options]
```
Initializes shadcn/ui in an existing project or creates a new project (when `--name` is provided). Optionally installs components in the same step.
| Flag | Short | Description | Default |
| ----------------------- | ----- | --------------------------------------------------------- | ------- |
| `--template <template>` | `-t` | Template (next, start, vite, next-monorepo, react-router) | — |
| `--preset [name]` | `-p` | Preset configuration (named, code, or URL) | — |
| `--yes` | `-y` | Skip confirmation prompt | `true` |
| `--defaults` | `-d` | Use defaults (`--template=next --preset=base-nova`) | `false` |
| `--force` | `-f` | Force overwrite existing configuration | `false` |
| `--cwd <cwd>` | `-c` | Working directory | current |
| `--name <name>` | `-n` | Name for new project | — |
| `--silent` | `-s` | Mute output | `false` |
| `--rtl` | | Enable RTL support | — |
| `--reinstall` | | Re-install existing UI components | `false` |
| `--monorepo` | | Scaffold a monorepo project | — |
| `--no-monorepo` | | Skip the monorepo prompt | — |
`npx shadcn@latest create` is an alias for `npx shadcn@latest init`.
### `add` — Add components
> **IMPORTANT:** To compare local components against upstream or to preview changes, ALWAYS use `npx shadcn@latest add <component> --dry-run`, `--diff`, or `--view`. NEVER fetch raw files from GitHub or other sources manually. The CLI handles registry resolution, file paths, and CSS diffing automatically.
```bash
npx shadcn@latest add [components...] [options]
```
Accepts component names, registry-prefixed names (`@magicui/shimmer-button`), URLs, or local paths.
| Flag | Short | Description | Default |
| --------------- | ----- | -------------------------------------------------------------------------------------------------------------------- | ------- |
| `--yes` | `-y` | Skip confirmation prompt | `false` |
| `--overwrite` | `-o` | Overwrite existing files | `false` |
| `--cwd <cwd>` | `-c` | Working directory | current |
| `--all` | `-a` | Add all available components | `false` |
| `--path <path>` | `-p` | Target path for the component | — |
| `--silent` | `-s` | Mute output | `false` |
| `--dry-run` | | Preview all changes without writing files | `false` |
| `--diff [path]` | | Show diffs. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
| `--view [path]` | | Show file contents. Without a path, shows the first 5 files. With a path, shows that file only (implies `--dry-run`) | — |
#### Dry-Run Mode
Use `--dry-run` to preview what `add` would do without writing any files. `--diff` and `--view` both imply `--dry-run`.
```bash
# Preview all changes.
npx shadcn@latest add button --dry-run
# Show diffs for all files (top 5).
npx shadcn@latest add button --diff
# Show the diff for a specific file.
npx shadcn@latest add button --diff button.tsx
# Show contents for all files (top 5).
npx shadcn@latest add button --view
# Show the full content of a specific file.
npx shadcn@latest add button --view button.tsx
# Works with URLs too.
npx shadcn@latest add https://api.npoint.io/abc123 --dry-run
# CSS diffs.
npx shadcn@latest add button --diff globals.css
```
**When to use dry-run:**
- When the user asks "what files will this add?" or "what will this change?" — use `--dry-run`.
- Before overwriting existing components — use `--diff` to preview the changes first.
- When the user wants to inspect component source code without installing — use `--view`.
- When checking what CSS changes would be made to `globals.css` — use `--diff globals.css`.
- When the user asks to review or audit third-party registry code before installing — use `--view` to inspect the source.
> **`npx shadcn@latest add --dry-run` vs `npx shadcn@latest view`:** Prefer `npx shadcn@latest add --dry-run/--diff/--view` over `npx shadcn@latest view` when the user wants to preview changes to their project. `npx shadcn@latest view` only shows raw registry metadata. `npx shadcn@latest add --dry-run` shows exactly what would happen in the user's project: resolved file paths, diffs against existing files, and CSS updates. Use `npx shadcn@latest view` only when the user wants to browse registry info without a project context.
#### Smart Merge from Upstream
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full workflow.
### `search` — Search registries
```bash
npx shadcn@latest search <registries...> [options]
```
Fuzzy search across registries. Also aliased as `npx shadcn@latest list`. Without `-q`, lists all items.
| Flag | Short | Description | Default |
| ------------------- | ----- | ---------------------- | ------- |
| `--query <query>` | `-q` | Search query | — |
| `--limit <number>` | `-l` | Max items per registry | `100` |
| `--offset <number>` | `-o` | Items to skip | `0` |
| `--cwd <cwd>` | `-c` | Working directory | current |
### `view` — View item details
```bash
npx shadcn@latest view <items...> [options]
```
Displays item info including file contents. Example: `npx shadcn@latest view @shadcn/button`.
### `docs` — Get component documentation URLs
```bash
npx shadcn@latest docs <components...> [options]
```
Outputs resolved URLs for component documentation, examples, and API references. Accepts one or more component names. Fetch the URLs to get the actual content.
Example output for `npx shadcn@latest docs input button`:
```
base radix
input
docs https://ui.shadcn.com/docs/components/radix/input
examples https://raw.githubusercontent.com/.../examples/input-example.tsx
button
docs https://ui.shadcn.com/docs/components/radix/button
examples https://raw.githubusercontent.com/.../examples/button-example.tsx
```
Some components include an `api` link to the underlying library (e.g. `cmdk` for the command component).
### `diff` — Check for updates
Do not use this command. Use `npx shadcn@latest add --diff` instead.
### `info` — Project information
```bash
npx shadcn@latest info [options]
```
Displays project info and `components.json` configuration. Run this first to discover the project's framework, aliases, Tailwind version, and resolved paths.
| Flag | Short | Description | Default |
| ------------- | ----- | ----------------- | ------- |
| `--cwd <cwd>` | `-c` | Working directory | current |
**Project Info fields:**
| Field | Type | Meaning |
| -------------------- | --------- | ------------------------------------------------------------------ |
| `framework` | `string` | Detected framework (`next`, `vite`, `react-router`, `start`, etc.) |
| `frameworkVersion` | `string` | Framework version (e.g. `15.2.4`) |
| `isSrcDir` | `boolean` | Whether the project uses a `src/` directory |
| `isRSC` | `boolean` | Whether React Server Components are enabled |
| `isTsx` | `boolean` | Whether the project uses TypeScript |
| `tailwindVersion` | `string` | `"v3"` or `"v4"` |
| `tailwindConfigFile` | `string` | Path to the Tailwind config file |
| `tailwindCssFile` | `string` | Path to the global CSS file |
| `aliasPrefix` | `string` | Import alias prefix (e.g. `@`, `~`, `@/`) |
| `packageManager` | `string` | Detected package manager (`npm`, `pnpm`, `yarn`, `bun`) |
**Components.json fields:**
| Field | Type | Meaning |
| -------------------- | --------- | ------------------------------------------------------------------------------------------ |
| `base` | `string` | Primitive library (`radix` or `base`) — determines component APIs and available props |
| `style` | `string` | Visual style (e.g. `nova`, `vega`) |
| `rsc` | `boolean` | RSC flag from config |
| `tsx` | `boolean` | TypeScript flag |
| `tailwind.config` | `string` | Tailwind config path |
| `tailwind.css` | `string` | Global CSS path — this is where custom CSS variables go |
| `iconLibrary` | `string` | Icon library — determines icon import package (e.g. `lucide-react`, `@tabler/icons-react`) |
| `aliases.components` | `string` | Component import alias (e.g. `@/components`) |
| `aliases.utils` | `string` | Utils import alias (e.g. `@/lib/utils`) |
| `aliases.ui` | `string` | UI component alias (e.g. `@/components/ui`) |
| `aliases.lib` | `string` | Lib alias (e.g. `@/lib`) |
| `aliases.hooks` | `string` | Hooks alias (e.g. `@/hooks`) |
| `resolvedPaths` | `object` | Absolute file-system paths for each alias |
| `registries` | `object` | Configured custom registries |
**Links fields:**
The `info` output includes a **Links** section with templated URLs for component docs, source, and examples. For resolved URLs, use `npx shadcn@latest docs <component>` instead.
### `build` — Build a custom registry
```bash
npx shadcn@latest build [registry] [options]
```
Builds `registry.json` into individual JSON files for distribution. Default input: `./registry.json`, default output: `./public/r`.
| Flag | Short | Description | Default |
| ----------------- | ----- | ----------------- | ------------ |
| `--output <path>` | `-o` | Output directory | `./public/r` |
| `--cwd <cwd>` | `-c` | Working directory | current |
---
## Templates
| Value | Framework | Monorepo support |
| -------------- | -------------- | ---------------- |
| `next` | Next.js | Yes |
| `vite` | Vite | Yes |
| `start` | TanStack Start | Yes |
| `react-router` | React Router | Yes |
| `astro` | Astro | Yes |
| `laravel` | Laravel | No |
All templates support monorepo scaffolding via the `--monorepo` flag. When passed, the CLI uses a monorepo-specific template directory (e.g. `next-monorepo`, `vite-monorepo`). When neither `--monorepo` nor `--no-monorepo` is passed, the CLI prompts interactively. Laravel does not support monorepo scaffolding.
---
## Presets
Three ways to specify a preset via `--preset`:
1. **Named:** `--preset base-nova` or `--preset radix-nova`
2. **Code:** `--preset a2r6bw` (base62 string, starts with lowercase `a`)
3. **URL:** `--preset "https://ui.shadcn.com/init?base=radix&style=nova&..."`
> **IMPORTANT:** Never try to decode, fetch, or resolve preset codes manually. Preset codes are opaque — pass them directly to `npx shadcn@latest init --preset <code>` and let the CLI handle resolution.
## Switching Presets
Ask the user first: **reinstall**, **merge**, or **skip** existing components?
- **Re-install** → `npx shadcn@latest init --preset <code> --force --reinstall`. Overwrites all component files with the new preset styles. Use when the user hasn't customized components.
- **Merge** → `npx shadcn@latest init --preset <code> --force --no-reinstall`, then run `npx shadcn@latest info` to get the list of installed components and use the [smart merge workflow](./SKILL.md#updating-components) to update them one by one, preserving local changes. Use when the user has customized components.
- **Skip** → `npx shadcn@latest init --preset <code> --force --no-reinstall`. Only updates config and CSS variables, leaves existing components as-is.

View File

@@ -0,0 +1,202 @@
# Customization & Theming
Components reference semantic CSS variable tokens. Change the variables to change every component.
## Contents
- How it works (CSS variables → Tailwind utilities → components)
- Color variables and OKLCH format
- Dark mode setup
- Changing the theme (presets, CSS variables)
- Adding custom colors (Tailwind v3 and v4)
- Border radius
- Customizing components (variants, className, wrappers)
- Checking for updates
---
## How It Works
1. CSS variables defined in `:root` (light) and `.dark` (dark mode).
2. Tailwind maps them to utilities: `bg-primary`, `text-muted-foreground`, etc.
3. Components use these utilities — changing a variable changes all components that reference it.
---
## Color Variables
Every color follows the `name` / `name-foreground` convention. The base variable is for backgrounds, `-foreground` is for text/icons on that background.
| Variable | Purpose |
| -------------------------------------------- | -------------------------------- |
| `--background` / `--foreground` | Page background and default text |
| `--card` / `--card-foreground` | Card surfaces |
| `--primary` / `--primary-foreground` | Primary buttons and actions |
| `--secondary` / `--secondary-foreground` | Secondary actions |
| `--muted` / `--muted-foreground` | Muted/disabled states |
| `--accent` / `--accent-foreground` | Hover and accent states |
| `--destructive` / `--destructive-foreground` | Error and destructive actions |
| `--border` | Default border color |
| `--input` | Form input borders |
| `--ring` | Focus ring color |
| `--chart-1` through `--chart-5` | Chart/data visualization |
| `--sidebar-*` | Sidebar-specific colors |
| `--surface` / `--surface-foreground` | Secondary surface |
Colors use OKLCH: `--primary: oklch(0.205 0 0)` where values are lightness (01), chroma (0 = gray), and hue (0360).
---
## Dark Mode
Class-based toggle via `.dark` on the root element. In Next.js, use `next-themes`:
```tsx
import { ThemeProvider } from "next-themes"
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
```
---
## Changing the Theme
```bash
# Apply a preset code from ui.shadcn.com.
npx shadcn@latest init --preset a2r6bw --force
# Switch to a named preset.
npx shadcn@latest init --preset radix-nova --force
npx shadcn@latest init --reinstall # update existing components to match
# Use a custom theme URL.
npx shadcn@latest init --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..." --force
```
Or edit CSS variables directly in `globals.css`.
---
## Adding Custom Colors
Add variables to the file at `tailwindCssFile` from `npx shadcn@latest info` (typically `globals.css`). Never create a new CSS file for this.
```css
/* 1. Define in the global CSS file. */
:root {
--warning: oklch(0.84 0.16 84);
--warning-foreground: oklch(0.28 0.07 46);
}
.dark {
--warning: oklch(0.41 0.11 46);
--warning-foreground: oklch(0.99 0.02 95);
}
```
```css
/* 2a. Register with Tailwind v4 (@theme inline). */
@theme inline {
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
}
```
When `tailwindVersion` is `"v3"` (check via `npx shadcn@latest info`), register in `tailwind.config.js` instead:
```js
// 2b. Register with Tailwind v3 (tailwind.config.js).
module.exports = {
theme: {
extend: {
colors: {
warning: "oklch(var(--warning) / <alpha-value>)",
"warning-foreground":
"oklch(var(--warning-foreground) / <alpha-value>)",
},
},
},
}
```
```tsx
// 3. Use in components.
<div className="bg-warning text-warning-foreground">Warning</div>
```
---
## Border Radius
`--radius` controls border radius globally. Components derive values from it (`rounded-lg` = `var(--radius)`, `rounded-md` = `calc(var(--radius) - 2px)`).
---
## Customizing Components
See also: [rules/styling.md](./rules/styling.md) for Incorrect/Correct examples.
Prefer these approaches in order:
### 1. Built-in variants
```tsx
<Button variant="outline" size="sm">Click</Button>
```
### 2. Tailwind classes via `className`
```tsx
<Card className="max-w-md mx-auto">...</Card>
```
### 3. Add a new variant
Edit the component source to add a variant via `cva`:
```tsx
// components/ui/button.tsx
warning: "bg-warning text-warning-foreground hover:bg-warning/90",
```
### 4. Wrapper components
Compose shadcn/ui primitives into higher-level components:
```tsx
export function ConfirmDialog({ title, description, onConfirm, children }) {
return (
<AlertDialog>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>Confirm</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
```
---
## Checking for Updates
```bash
npx shadcn@latest add button --diff
```
To preview exactly what would change before updating, use `--dry-run` and `--diff`:
```bash
npx shadcn@latest add button --dry-run # see all affected files
npx shadcn@latest add button --diff button.tsx # see the diff for a specific file
```
See [Updating Components in SKILL.md](./SKILL.md#updating-components) for the full smart merge workflow.

View File

@@ -0,0 +1,47 @@
{
"skill_name": "shadcn",
"evals": [
{
"id": 1,
"prompt": "I'm building a Next.js app with shadcn/ui (base-nova preset, lucide icons). Create a settings form component with fields for: full name, email address, and notification preferences (email, SMS, push notifications as toggle options). Add validation states for required fields.",
"expected_output": "A React component using FieldGroup, Field, ToggleGroup, data-invalid/aria-invalid validation, gap-* spacing, and semantic colors.",
"files": [],
"expectations": [
"Uses FieldGroup and Field components for form layout instead of raw div with space-y",
"Uses Switch for independent on/off notification toggles (not looping Button with manual active state)",
"Uses data-invalid on Field and aria-invalid on the input control for validation states",
"Uses gap-* (e.g. gap-4, gap-6) instead of space-y-* or space-x-* for spacing",
"Uses semantic color tokens (e.g. bg-background, text-muted-foreground, text-destructive) instead of raw colors like bg-red-500",
"No manual dark: color overrides"
]
},
{
"id": 2,
"prompt": "Create a dialog component for editing a user profile. It should have the user's avatar at the top, input fields for name and bio, and Save/Cancel buttons with appropriate icons. Using shadcn/ui with radix-nova preset and tabler icons.",
"expected_output": "A React component with DialogTitle, Avatar+AvatarFallback, data-icon on icon buttons, no icon sizing classes, tabler icon imports.",
"files": [],
"expectations": [
"Includes DialogTitle for accessibility (visible or with sr-only class)",
"Avatar component includes AvatarFallback",
"Icons on buttons use the data-icon attribute (data-icon=\"inline-start\" or data-icon=\"inline-end\")",
"No sizing classes on icons inside components (no size-4, w-4, h-4, etc.)",
"Uses tabler icons (@tabler/icons-react) instead of lucide-react",
"Uses asChild for custom triggers (radix preset)"
]
},
{
"id": 3,
"prompt": "Create a dashboard component that shows 4 stat cards in a grid. Each card has a title, large number, percentage change badge, and a loading skeleton state. Using shadcn/ui with base-nova preset and lucide icons.",
"expected_output": "A React component with full Card composition, Skeleton for loading, Badge for changes, semantic colors, gap-* spacing.",
"files": [],
"expectations": [
"Uses full Card composition with CardHeader, CardTitle, CardContent (not dumping everything into CardContent)",
"Uses Skeleton component for loading placeholders instead of custom animate-pulse divs",
"Uses Badge component for percentage change instead of custom styled spans",
"Uses semantic color tokens instead of raw color values like bg-green-500 or text-red-600",
"Uses gap-* instead of space-y-* or space-x-* for spacing",
"Uses size-* when width and height are equal instead of separate w-* h-*"
]
}
]
}

View File

@@ -0,0 +1,94 @@
# shadcn MCP Server
The CLI includes an MCP server that lets AI assistants search, browse, view, and install components from registries.
---
## Setup
```bash
shadcn mcp # start the MCP server (stdio)
shadcn mcp init # write config for your editor
```
Editor config files:
| Editor | Config file |
|--------|------------|
| Claude Code | `.mcp.json` |
| Cursor | `.cursor/mcp.json` |
| VS Code | `.vscode/mcp.json` |
| OpenCode | `opencode.json` |
| Codex | `~/.codex/config.toml` (manual) |
---
## Tools
> **Tip:** MCP tools handle registry operations (search, view, install). For project configuration (aliases, framework, Tailwind version), use `npx shadcn@latest info` — there is no MCP equivalent.
### `shadcn:get_project_registries`
Returns registry names from `components.json`. Errors if no `components.json` exists.
**Input:** none
### `shadcn:list_items_in_registries`
Lists all items from one or more registries.
**Input:** `registries` (string[]), `limit` (number, optional), `offset` (number, optional)
### `shadcn:search_items_in_registries`
Fuzzy search across registries.
**Input:** `registries` (string[]), `query` (string), `limit` (number, optional), `offset` (number, optional)
### `shadcn:view_items_in_registries`
View item details including full file contents.
**Input:** `items` (string[]) — e.g. `["@shadcn/button", "@shadcn/card"]`
### `shadcn:get_item_examples_from_registries`
Find usage examples and demos with source code.
**Input:** `registries` (string[]), `query` (string) — e.g. `"accordion-demo"`, `"button example"`
### `shadcn:get_add_command_for_items`
Returns the CLI install command.
**Input:** `items` (string[]) — e.g. `["@shadcn/button"]`
### `shadcn:get_audit_checklist`
Returns a checklist for verifying components (imports, deps, lint, TypeScript).
**Input:** none
---
## Configuring Registries
Registries are set in `components.json`. The `@shadcn` registry is always built-in.
```json
{
"registries": {
"@acme": "https://acme.com/r/{name}.json",
"@private": {
"url": "https://private.com/r/{name}.json",
"headers": { "Authorization": "Bearer ${MY_TOKEN}" }
}
}
}
```
- Names must start with `@`.
- URLs must contain `{name}`.
- `${VAR}` references are resolved from environment variables.
Community registry index: `https://ui.shadcn.com/r/registries.json`

View File

@@ -0,0 +1,306 @@
# Base vs Radix
API differences between `base` and `radix`. Check the `base` field from `npx shadcn@latest info`.
## Contents
- Composition: asChild vs render
- Button / trigger as non-button element
- Select (items prop, placeholder, positioning, multiple, object values)
- ToggleGroup (type vs multiple)
- Slider (scalar vs array)
- Accordion (type and defaultValue)
---
## Composition: asChild (radix) vs render (base)
Radix uses `asChild` to replace the default element. Base uses `render`. Don't wrap triggers in extra elements.
**Incorrect:**
```tsx
<DialogTrigger>
<div>
<Button>Open</Button>
</div>
</DialogTrigger>
```
**Correct (radix):**
```tsx
<DialogTrigger asChild>
<Button>Open</Button>
</DialogTrigger>
```
**Correct (base):**
```tsx
<DialogTrigger render={<Button />}>Open</DialogTrigger>
```
This applies to all trigger and close components: `DialogTrigger`, `SheetTrigger`, `AlertDialogTrigger`, `DropdownMenuTrigger`, `PopoverTrigger`, `TooltipTrigger`, `CollapsibleTrigger`, `DialogClose`, `SheetClose`, `NavigationMenuLink`, `BreadcrumbLink`, `SidebarMenuButton`, `Badge`, `Item`.
---
## Button / trigger as non-button element (base only)
When `render` changes an element to a non-button (`<a>`, `<span>`), add `nativeButton={false}`.
**Incorrect (base):** missing `nativeButton={false}`.
```tsx
<Button render={<a href="/docs" />}>Read the docs</Button>
```
**Correct (base):**
```tsx
<Button render={<a href="/docs" />} nativeButton={false}>
Read the docs
</Button>
```
**Correct (radix):**
```tsx
<Button asChild>
<a href="/docs">Read the docs</a>
</Button>
```
Same for triggers whose `render` is not a `Button`:
```tsx
// base.
<PopoverTrigger render={<InputGroupAddon />} nativeButton={false}>
Pick date
</PopoverTrigger>
```
---
## Select
**items prop (base only).** Base requires an `items` prop on the root. Radix uses inline JSX only.
**Incorrect (base):**
```tsx
<Select>
<SelectTrigger><SelectValue placeholder="Select a fruit" /></SelectTrigger>
</Select>
```
**Correct (base):**
```tsx
const items = [
{ label: "Select a fruit", value: null },
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
]
<Select items={items}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
```
**Correct (radix):**
```tsx
<Select>
<SelectTrigger>
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
```
**Placeholder.** Base uses a `{ value: null }` item in the items array. Radix uses `<SelectValue placeholder="...">`.
**Content positioning.** Base uses `alignItemWithTrigger`. Radix uses `position`.
```tsx
// base.
<SelectContent alignItemWithTrigger={false} side="bottom">
// radix.
<SelectContent position="popper">
```
---
## Select — multiple selection and object values (base only)
Base supports `multiple`, render-function children on `SelectValue`, and object values with `itemToStringValue`. Radix is single-select with string values only.
**Correct (base — multiple selection):**
```tsx
<Select items={items} multiple defaultValue={[]}>
<SelectTrigger>
<SelectValue>
{(value: string[]) => value.length === 0 ? "Select fruits" : `${value.length} selected`}
</SelectValue>
</SelectTrigger>
...
</Select>
```
**Correct (base — object values):**
```tsx
<Select defaultValue={plans[0]} itemToStringValue={(plan) => plan.name}>
<SelectTrigger>
<SelectValue>{(value) => value.name}</SelectValue>
</SelectTrigger>
...
</Select>
```
---
## ToggleGroup
Base uses a `multiple` boolean prop. Radix uses `type="single"` or `type="multiple"`.
**Incorrect (base):**
```tsx
<ToggleGroup type="single" defaultValue="daily">
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
</ToggleGroup>
```
**Correct (base):**
```tsx
// Single (no prop needed), defaultValue is always an array.
<ToggleGroup defaultValue={["daily"]} spacing={2}>
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
</ToggleGroup>
// Multi-selection.
<ToggleGroup multiple>
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
</ToggleGroup>
```
**Correct (radix):**
```tsx
// Single, defaultValue is a string.
<ToggleGroup type="single" defaultValue="daily" spacing={2}>
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
</ToggleGroup>
// Multi-selection.
<ToggleGroup type="multiple">
<ToggleGroupItem value="bold">Bold</ToggleGroupItem>
<ToggleGroupItem value="italic">Italic</ToggleGroupItem>
</ToggleGroup>
```
**Controlled single value:**
```tsx
// base — wrap/unwrap arrays.
const [value, setValue] = React.useState("normal")
<ToggleGroup value={[value]} onValueChange={(v) => setValue(v[0])}>
// radix — plain string.
const [value, setValue] = React.useState("normal")
<ToggleGroup type="single" value={value} onValueChange={setValue}>
```
---
## Slider
Base accepts a plain number for a single thumb. Radix always requires an array.
**Incorrect (base):**
```tsx
<Slider defaultValue={[50]} max={100} step={1} />
```
**Correct (base):**
```tsx
<Slider defaultValue={50} max={100} step={1} />
```
**Correct (radix):**
```tsx
<Slider defaultValue={[50]} max={100} step={1} />
```
Both use arrays for range sliders. Controlled `onValueChange` in base may need a cast:
```tsx
// base.
const [value, setValue] = React.useState([0.3, 0.7])
<Slider value={value} onValueChange={(v) => setValue(v as number[])} />
// radix.
const [value, setValue] = React.useState([0.3, 0.7])
<Slider value={value} onValueChange={setValue} />
```
---
## Accordion
Radix requires `type="single"` or `type="multiple"` and supports `collapsible`. `defaultValue` is a string. Base uses no `type` prop, uses `multiple` boolean, and `defaultValue` is always an array.
**Incorrect (base):**
```tsx
<Accordion type="single" collapsible defaultValue="item-1">
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
```
**Correct (base):**
```tsx
<Accordion defaultValue={["item-1"]}>
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
// Multi-select.
<Accordion multiple defaultValue={["item-1", "item-2"]}>
<AccordionItem value="item-1">...</AccordionItem>
<AccordionItem value="item-2">...</AccordionItem>
</Accordion>
```
**Correct (radix):**
```tsx
<Accordion type="single" collapsible defaultValue="item-1">
<AccordionItem value="item-1">...</AccordionItem>
</Accordion>
```

View File

@@ -0,0 +1,195 @@
# Component Composition
## Contents
- Items always inside their Group component
- Callouts use Alert
- Empty states use Empty component
- Toast notifications use sonner
- Choosing between overlay components
- Dialog, Sheet, and Drawer always need a Title
- Card structure
- Button has no isPending or isLoading prop
- TabsTrigger must be inside TabsList
- Avatar always needs AvatarFallback
- Use Separator instead of raw hr or border divs
- Use Skeleton for loading placeholders
- Use Badge instead of custom styled spans
---
## Items always inside their Group component
Never render items directly inside the content container.
**Incorrect:**
```tsx
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectContent>
```
**Correct:**
```tsx
<SelectContent>
<SelectGroup>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectGroup>
</SelectContent>
```
This applies to all group-based components:
| Item | Group |
|------|-------|
| `SelectItem`, `SelectLabel` | `SelectGroup` |
| `DropdownMenuItem`, `DropdownMenuLabel`, `DropdownMenuSub` | `DropdownMenuGroup` |
| `MenubarItem` | `MenubarGroup` |
| `ContextMenuItem` | `ContextMenuGroup` |
| `CommandItem` | `CommandGroup` |
---
## Callouts use Alert
```tsx
<Alert>
<AlertTitle>Warning</AlertTitle>
<AlertDescription>Something needs attention.</AlertDescription>
</Alert>
```
---
## Empty states use Empty component
```tsx
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon"><FolderIcon /></EmptyMedia>
<EmptyTitle>No projects yet</EmptyTitle>
<EmptyDescription>Get started by creating a new project.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button>Create Project</Button>
</EmptyContent>
</Empty>
```
---
## Toast notifications use sonner
```tsx
import { toast } from "sonner"
toast.success("Changes saved.")
toast.error("Something went wrong.")
toast("File deleted.", {
action: { label: "Undo", onClick: () => undoDelete() },
})
```
---
## Choosing between overlay components
| Use case | Component |
|----------|-----------|
| Focused task that requires input | `Dialog` |
| Destructive action confirmation | `AlertDialog` |
| Side panel with details or filters | `Sheet` |
| Mobile-first bottom panel | `Drawer` |
| Quick info on hover | `HoverCard` |
| Small contextual content on click | `Popover` |
---
## Dialog, Sheet, and Drawer always need a Title
`DialogTitle`, `SheetTitle`, `DrawerTitle` are required for accessibility. Use `className="sr-only"` if visually hidden.
```tsx
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Update your profile.</DialogDescription>
</DialogHeader>
...
</DialogContent>
```
---
## Card structure
Use full composition — don't dump everything into `CardContent`:
```tsx
<Card>
<CardHeader>
<CardTitle>Team Members</CardTitle>
<CardDescription>Manage your team.</CardDescription>
</CardHeader>
<CardContent>...</CardContent>
<CardFooter>
<Button>Invite</Button>
</CardFooter>
</Card>
```
---
## Button has no isPending or isLoading prop
Compose with `Spinner` + `data-icon` + `disabled`:
```tsx
<Button disabled>
<Spinner data-icon="inline-start" />
Saving...
</Button>
```
---
## TabsTrigger must be inside TabsList
Never render `TabsTrigger` directly inside `Tabs` — always wrap in `TabsList`:
```tsx
<Tabs defaultValue="account">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">...</TabsContent>
</Tabs>
```
---
## Avatar always needs AvatarFallback
Always include `AvatarFallback` for when the image fails to load:
```tsx
<Avatar>
<AvatarImage src="/avatar.png" alt="User" />
<AvatarFallback>JD</AvatarFallback>
</Avatar>
```
---
## Use existing components instead of custom markup
| Instead of | Use |
|---|---|
| `<hr>` or `<div className="border-t">` | `<Separator />` |
| `<div className="animate-pulse">` with styled divs | `<Skeleton className="h-4 w-3/4" />` |
| `<span className="rounded-full bg-green-100 ...">` | `<Badge variant="secondary">` |

View File

@@ -0,0 +1,192 @@
# Forms & Inputs
## Contents
- Forms use FieldGroup + Field
- InputGroup requires InputGroupInput/InputGroupTextarea
- Buttons inside inputs use InputGroup + InputGroupAddon
- Option sets (27 choices) use ToggleGroup
- FieldSet + FieldLegend for grouping related fields
- Field validation and disabled states
---
## Forms use FieldGroup + Field
Always use `FieldGroup` + `Field` — never raw `div` with `space-y-*`:
```tsx
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" type="email" />
</Field>
<Field>
<FieldLabel htmlFor="password">Password</FieldLabel>
<Input id="password" type="password" />
</Field>
</FieldGroup>
```
Use `Field orientation="horizontal"` for settings pages. Use `FieldLabel className="sr-only"` for visually hidden labels.
**Choosing form controls:**
- Simple text input → `Input`
- Dropdown with predefined options → `Select`
- Searchable dropdown → `Combobox`
- Native HTML select (no JS) → `native-select`
- Boolean toggle → `Switch` (for settings) or `Checkbox` (for forms)
- Single choice from few options → `RadioGroup`
- Toggle between 25 options → `ToggleGroup` + `ToggleGroupItem`
- OTP/verification code → `InputOTP`
- Multi-line text → `Textarea`
---
## InputGroup requires InputGroupInput/InputGroupTextarea
Never use raw `Input` or `Textarea` inside an `InputGroup`.
**Incorrect:**
```tsx
<InputGroup>
<Input placeholder="Search..." />
</InputGroup>
```
**Correct:**
```tsx
import { InputGroup, InputGroupInput } from "@/components/ui/input-group"
<InputGroup>
<InputGroupInput placeholder="Search..." />
</InputGroup>
```
---
## Buttons inside inputs use InputGroup + InputGroupAddon
Never place a `Button` directly inside or adjacent to an `Input` with custom positioning.
**Incorrect:**
```tsx
<div className="relative">
<Input placeholder="Search..." className="pr-10" />
<Button className="absolute right-0 top-0" size="icon">
<SearchIcon />
</Button>
</div>
```
**Correct:**
```tsx
import { InputGroup, InputGroupInput, InputGroupAddon } from "@/components/ui/input-group"
<InputGroup>
<InputGroupInput placeholder="Search..." />
<InputGroupAddon>
<Button size="icon">
<SearchIcon data-icon="inline-start" />
</Button>
</InputGroupAddon>
</InputGroup>
```
---
## Option sets (27 choices) use ToggleGroup
Don't manually loop `Button` components with active state.
**Incorrect:**
```tsx
const [selected, setSelected] = useState("daily")
<div className="flex gap-2">
{["daily", "weekly", "monthly"].map((option) => (
<Button
key={option}
variant={selected === option ? "default" : "outline"}
onClick={() => setSelected(option)}
>
{option}
</Button>
))}
</div>
```
**Correct:**
```tsx
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
<ToggleGroup spacing={2}>
<ToggleGroupItem value="daily">Daily</ToggleGroupItem>
<ToggleGroupItem value="weekly">Weekly</ToggleGroupItem>
<ToggleGroupItem value="monthly">Monthly</ToggleGroupItem>
</ToggleGroup>
```
Combine with `Field` for labelled toggle groups:
```tsx
<Field orientation="horizontal">
<FieldTitle id="theme-label">Theme</FieldTitle>
<ToggleGroup aria-labelledby="theme-label" spacing={2}>
<ToggleGroupItem value="light">Light</ToggleGroupItem>
<ToggleGroupItem value="dark">Dark</ToggleGroupItem>
<ToggleGroupItem value="system">System</ToggleGroupItem>
</ToggleGroup>
</Field>
```
> **Note:** `defaultValue` and `type`/`multiple` props differ between base and radix. See [base-vs-radix.md](./base-vs-radix.md#togglegroup).
---
## FieldSet + FieldLegend for grouping related fields
Use `FieldSet` + `FieldLegend` for related checkboxes, radios, or switches — not `div` with a heading:
```tsx
<FieldSet>
<FieldLegend variant="label">Preferences</FieldLegend>
<FieldDescription>Select all that apply.</FieldDescription>
<FieldGroup className="gap-3">
<Field orientation="horizontal">
<Checkbox id="dark" />
<FieldLabel htmlFor="dark" className="font-normal">Dark mode</FieldLabel>
</Field>
</FieldGroup>
</FieldSet>
```
---
## Field validation and disabled states
Both attributes are needed — `data-invalid`/`data-disabled` styles the field (label, description), while `aria-invalid`/`disabled` styles the control.
```tsx
// Invalid.
<Field data-invalid>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" aria-invalid />
<FieldDescription>Invalid email address.</FieldDescription>
</Field>
// Disabled.
<Field data-disabled>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" disabled />
</Field>
```
Works for all controls: `Input`, `Textarea`, `Select`, `Checkbox`, `RadioGroupItem`, `Switch`, `Slider`, `NativeSelect`, `InputOTP`.

View File

@@ -0,0 +1,101 @@
# Icons
**Always use the project's configured `iconLibrary` for imports.** Check the `iconLibrary` field from project context: `lucide``lucide-react`, `tabler``@tabler/icons-react`, etc. Never assume `lucide-react`.
---
## Icons in Button use data-icon attribute
Add `data-icon="inline-start"` (prefix) or `data-icon="inline-end"` (suffix) to the icon. No sizing classes on the icon.
**Incorrect:**
```tsx
<Button>
<SearchIcon className="mr-2 size-4" />
Search
</Button>
```
**Correct:**
```tsx
<Button>
<SearchIcon data-icon="inline-start"/>
Search
</Button>
<Button>
Next
<ArrowRightIcon data-icon="inline-end"/>
</Button>
```
---
## No sizing classes on icons inside components
Components handle icon sizing via CSS. Don't add `size-4`, `w-4 h-4`, or other sizing classes to icons inside `Button`, `DropdownMenuItem`, `Alert`, `Sidebar*`, or other shadcn components. Unless the user explicitly asks for custom icon sizes.
**Incorrect:**
```tsx
<Button>
<SearchIcon className="size-4" data-icon="inline-start" />
Search
</Button>
<DropdownMenuItem>
<SettingsIcon className="mr-2 size-4" />
Settings
</DropdownMenuItem>
```
**Correct:**
```tsx
<Button>
<SearchIcon data-icon="inline-start" />
Search
</Button>
<DropdownMenuItem>
<SettingsIcon />
Settings
</DropdownMenuItem>
```
---
## Pass icons as component objects, not string keys
Use `icon={CheckIcon}`, not a string key to a lookup map.
**Incorrect:**
```tsx
const iconMap = {
check: CheckIcon,
alert: AlertIcon,
}
function StatusBadge({ icon }: { icon: string }) {
const Icon = iconMap[icon]
return <Icon />
}
<StatusBadge icon="check" />
```
**Correct:**
```tsx
// Import from the project's configured iconLibrary (e.g. lucide-react, @tabler/icons-react).
import { CheckIcon } from "lucide-react"
function StatusBadge({ icon: Icon }: { icon: React.ComponentType }) {
return <Icon />
}
<StatusBadge icon={CheckIcon} />
```

View File

@@ -0,0 +1,162 @@
# Styling & Customization
See [customization.md](../customization.md) for theming, CSS variables, and adding custom colors.
## Contents
- Semantic colors
- Built-in variants first
- className for layout only
- No space-x-* / space-y-*
- Prefer size-* over w-* h-* when equal
- Prefer truncate shorthand
- No manual dark: color overrides
- Use cn() for conditional classes
- No manual z-index on overlay components
---
## Semantic colors
**Incorrect:**
```tsx
<div className="bg-blue-500 text-white">
<p className="text-gray-600">Secondary text</p>
</div>
```
**Correct:**
```tsx
<div className="bg-primary text-primary-foreground">
<p className="text-muted-foreground">Secondary text</p>
</div>
```
---
## No raw color values for status/state indicators
For positive, negative, or status indicators, use Badge variants, semantic tokens like `text-destructive`, or define custom CSS variables — don't reach for raw Tailwind colors.
**Incorrect:**
```tsx
<span className="text-emerald-600">+20.1%</span>
<span className="text-green-500">Active</span>
<span className="text-red-600">-3.2%</span>
```
**Correct:**
```tsx
<Badge variant="secondary">+20.1%</Badge>
<Badge>Active</Badge>
<span className="text-destructive">-3.2%</span>
```
If you need a success/positive color that doesn't exist as a semantic token, use a Badge variant or ask the user about adding a custom CSS variable to the theme (see [customization.md](../customization.md)).
---
## Built-in variants first
**Incorrect:**
```tsx
<Button className="border border-input bg-transparent hover:bg-accent">
Click me
</Button>
```
**Correct:**
```tsx
<Button variant="outline">Click me</Button>
```
---
## className for layout only
Use `className` for layout (e.g. `max-w-md`, `mx-auto`, `mt-4`), **not** for overriding component colors or typography. To change colors, use semantic tokens, built-in variants, or CSS variables.
**Incorrect:**
```tsx
<Card className="bg-blue-100 text-blue-900 font-bold">
<CardContent>Dashboard</CardContent>
</Card>
```
**Correct:**
```tsx
<Card className="max-w-md mx-auto">
<CardContent>Dashboard</CardContent>
</Card>
```
To customize a component's appearance, prefer these approaches in order:
1. **Built-in variants**`variant="outline"`, `variant="destructive"`, etc.
2. **Semantic color tokens**`bg-primary`, `text-muted-foreground`.
3. **CSS variables** — define custom colors in the global CSS file (see [customization.md](../customization.md)).
---
## No space-x-* / space-y-*
Use `gap-*` instead. `space-y-4``flex flex-col gap-4`. `space-x-2``flex gap-2`.
```tsx
<div className="flex flex-col gap-4">
<Input />
<Input />
<Button>Submit</Button>
</div>
```
---
## Prefer size-* over w-* h-* when equal
`size-10` not `w-10 h-10`. Applies to icons, avatars, skeletons, etc.
---
## Prefer truncate shorthand
`truncate` not `overflow-hidden text-ellipsis whitespace-nowrap`.
---
## No manual dark: color overrides
Use semantic tokens — they handle light/dark via CSS variables. `bg-background text-foreground` not `bg-white dark:bg-gray-950`.
---
## Use cn() for conditional classes
Use the `cn()` utility from the project for conditional or merged class names. Don't write manual ternaries in className strings.
**Incorrect:**
```tsx
<div className={`flex items-center ${isActive ? "bg-primary text-primary-foreground" : "bg-muted"}`}>
```
**Correct:**
```tsx
import { cn } from "@/lib/utils"
<div className={cn("flex items-center", isActive ? "bg-primary text-primary-foreground" : "bg-muted")}>
```
---
## No manual z-index on overlay components
`Dialog`, `Sheet`, `Drawer`, `AlertDialog`, `DropdownMenu`, `Popover`, `Tooltip`, `HoverCard` handle their own stacking. Never add `z-50` or `z-[999]`.

1
.roo/skills/frontend-design Symbolic link
View File

@@ -0,0 +1 @@
../../.agents/skills/frontend-design

1
.roo/skills/shadcn Symbolic link
View File

@@ -0,0 +1 @@
../../.agents/skills/shadcn

32
backend/dist/src/app.js vendored Normal file
View File

@@ -0,0 +1,32 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildApp = buildApp;
const fastify_1 = __importDefault(require("fastify"));
const cors_1 = __importDefault(require("@fastify/cors"));
const helmet_1 = __importDefault(require("@fastify/helmet"));
const rate_limit_1 = __importDefault(require("@fastify/rate-limit"));
const health_1 = __importDefault(require("./routes/health"));
const import_1 = __importDefault(require("./routes/import"));
const games_1 = __importDefault(require("./routes/games"));
const metadata_1 = __importDefault(require("./routes/metadata"));
function buildApp() {
const app = (0, fastify_1.default)({
logger: false,
});
void app.register(cors_1.default, { origin: true });
void app.register(helmet_1.default);
void app.register(rate_limit_1.default, { max: 1000, timeWindow: '1 minute' });
void app.register(health_1.default, { prefix: '/api' });
void app.register(import_1.default, { prefix: '/api' });
void app.register(games_1.default, { prefix: '/api' });
void app.register(metadata_1.default, { prefix: '/api' });
return app;
}
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-07
*/

10
backend/dist/src/config.js vendored Normal file
View File

@@ -0,0 +1,10 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.IMPORT_CONCURRENCY = void 0;
const os_1 = __importDefault(require("os"));
const envVal = Number.parseInt(process.env.IMPORT_CONCURRENCY ?? '', 10);
exports.IMPORT_CONCURRENCY = Number.isFinite(envVal) && envVal > 0 ? envVal : Math.min(8, Math.max(1, os_1.default.cpus().length - 1));
exports.default = exports.IMPORT_CONCURRENCY;

View File

@@ -0,0 +1,212 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.GamesController = void 0;
const prisma_1 = require("../plugins/prisma");
class GamesController {
/**
* Listar todos los juegos con sus plataformas y compras
*/
static async listGames() {
return await prisma_1.prisma.game.findMany({
include: {
gamePlatforms: {
include: {
platform: true,
},
},
purchases: true,
},
orderBy: {
title: 'asc',
},
});
}
/**
* Obtener un juego por ID
*/
static async getGameById(id) {
const game = await prisma_1.prisma.game.findUnique({
where: { id },
include: {
gamePlatforms: {
include: {
platform: true,
},
},
purchases: true,
artworks: true,
tags: true,
},
});
if (!game) {
throw new Error('Juego no encontrado');
}
return game;
}
/**
* Listar juegos por fuente (rom, manual, igdb, rawg, etc.)
*/
static async listGamesBySource(source) {
return await prisma_1.prisma.game.findMany({
where: { source },
include: {
gamePlatforms: {
include: {
platform: true,
},
},
purchases: true,
},
orderBy: {
title: 'asc',
},
});
}
/**
* Crear un juego nuevo
*/
static async createGame(input) {
const { title, platformId, description, priceCents, currency, store, date, condition, source, sourceId, } = input;
// Generar slug basado en el título
const slug = title
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^\w-]/g, '');
const gameData = {
title,
slug: `${slug}-${Date.now()}`, // Hacer slug único agregando timestamp
description: description || null,
source: source || 'manual',
sourceId: sourceId || null,
};
// Si se proporciona una plataforma, crearla en gamePlatforms
if (platformId) {
gameData.gamePlatforms = {
create: {
platformId,
},
};
}
// Si se proporciona precio, crear en purchases
if (priceCents) {
gameData.purchases = {
create: {
priceCents,
currency: currency || 'USD',
store: store || null,
date: date ? new Date(date) : new Date(),
},
};
}
return await prisma_1.prisma.game.create({
data: gameData,
include: {
gamePlatforms: {
include: {
platform: true,
},
},
purchases: true,
},
});
}
/**
* Actualizar un juego existente
*/
static async updateGame(id, input) {
const { title, platformId, description, priceCents, currency, store, date, source, sourceId } = input;
const updateData = {};
if (title !== undefined) {
updateData.title = title;
// Regenerar slug si cambia el título
const slug = title
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^\w-]/g, '');
updateData.slug = `${slug}-${Date.now()}`;
}
if (description !== undefined) {
updateData.description = description;
}
if (source !== undefined) {
updateData.source = source;
}
if (sourceId !== undefined) {
updateData.sourceId = sourceId;
}
const game = await prisma_1.prisma.game.update({
where: { id },
data: updateData,
include: {
gamePlatforms: {
include: {
platform: true,
},
},
purchases: true,
},
});
// Si se actualiza plataforma, sincronizar
if (platformId !== undefined) {
// Eliminar relaciones antiguas
await prisma_1.prisma.gamePlatform.deleteMany({
where: { gameId: id },
});
// Crear nueva relación si se proporcionó platformId
if (platformId) {
await prisma_1.prisma.gamePlatform.create({
data: {
gameId: id,
platformId,
},
});
}
}
// Si se actualiza precio, agregar nueva compra (crear histórico)
if (priceCents !== undefined) {
await prisma_1.prisma.purchase.create({
data: {
gameId: id,
priceCents,
currency: currency || 'USD',
store: store || null,
date: date ? new Date(date) : new Date(),
},
});
}
// Retornar el juego actualizado
return await prisma_1.prisma.game.findUniqueOrThrow({
where: { id },
include: {
gamePlatforms: {
include: {
platform: true,
},
},
purchases: true,
},
});
}
/**
* Eliminar un juego (y sus relaciones en cascada)
*/
static async deleteGame(id) {
// Validar que el juego existe
const game = await prisma_1.prisma.game.findUnique({ where: { id } });
if (!game) {
throw new Error('Juego no encontrado');
}
// Eliminar todas las relaciones (Prisma maneja cascada según schema)
await prisma_1.prisma.game.delete({
where: { id },
});
return { message: 'Juego eliminado correctamente' };
}
}
exports.GamesController = GamesController;
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-03-18
* Actualizado para soportar fuente (source) en juegos
*/

30
backend/dist/src/index.js vendored Normal file
View File

@@ -0,0 +1,30 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const dotenv_1 = __importDefault(require("dotenv"));
const app_1 = require("./app");
dotenv_1.default.config();
const port = Number(process.env.PORT ?? 3000);
const app = (0, app_1.buildApp)();
const start = async () => {
try {
await app.listen({ port, host: '0.0.0.0' });
// eslint-disable-next-line no-console
console.log(`Server listening on http://0.0.0.0:${port}`);
}
catch (err) {
app.log.error(err);
process.exit(1);
}
};
// Start only when run directly (avoids starting during tests)
if (require.main === module) {
start();
}
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-07
*/

116
backend/dist/src/jobs/importRunner.js vendored Normal file
View File

@@ -0,0 +1,116 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.runner = exports.ImportRunner = void 0;
const config_1 = require("../config");
class ImportRunner {
constructor(concurrency) {
this.queue = [];
this.runningCount = 0;
this.completedCount = 0;
this.isRunning = false;
this.stopped = false;
this.concurrency = Math.max(1, concurrency ?? config_1.IMPORT_CONCURRENCY);
}
start() {
if (this.isRunning)
return;
this.isRunning = true;
this.stopped = false;
this._processQueue();
}
async stopAndWait() {
this.stop();
// wait until any running tasks finish
while (this.runningCount > 0) {
await new Promise((res) => setImmediate(res));
}
}
stop() {
if (this.stopped)
return;
this.isRunning = false;
this.stopped = true;
// reject and count all pending tasks (schedule rejection to avoid unhandled rejections)
while (this.queue.length > 0) {
const task = this.queue.shift();
this.completedCount++;
// attach a noop catch so Node doesn't treat the rejection as unhandled
if (task.promise) {
task.promise.catch(() => { });
}
setImmediate(() => {
try {
task.reject(new Error('ImportRunner stopped'));
}
catch (e) {
// noop
}
});
}
}
enqueue(fn) {
if (this.stopped) {
return Promise.reject(new Error('ImportRunner stopped'));
}
let resolveFn;
let rejectFn;
const p = new Promise((res, rej) => {
resolveFn = res;
rejectFn = rej;
});
this.queue.push({ fn, resolve: resolveFn, reject: rejectFn, promise: p });
// start or continue processing immediately so the first task begins right away
if (!this.isRunning) {
this.start();
}
else {
this._processQueue();
}
return p;
}
getStatus() {
return {
queued: this.queue.length,
running: this.runningCount,
completed: this.completedCount,
concurrency: this.concurrency,
};
}
_processQueue() {
if (!this.isRunning)
return;
while (this.runningCount < this.concurrency && this.queue.length > 0) {
const task = this.queue.shift();
const result = Promise.resolve().then(() => task.fn());
this.runningCount++;
result
.then((res) => {
this.runningCount--;
this.completedCount++;
try {
task.resolve(res);
}
catch (e) {
// noop
}
setImmediate(() => this._processQueue());
})
.catch((err) => {
this.runningCount--;
this.completedCount++;
console.error(err);
try {
task.reject(err);
}
catch (e) {
// noop
}
setImmediate(() => this._processQueue());
});
}
}
}
exports.ImportRunner = ImportRunner;
exports.runner = new ImportRunner();
exports.runner.start();
exports.default = exports.runner;

View File

@@ -0,0 +1,19 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.detectFormat = detectFormat;
const path_1 = __importDefault(require("path"));
function detectFormat(filename) {
const ext = path_1.default.extname(filename || '').toLowerCase();
if (!ext)
return 'bin';
const map = {
'.zip': 'zip',
'.7z': '7z',
'.chd': 'chd',
};
return map[ext] ?? ext.replace(/^\./, '');
}
exports.default = detectFormat;

12
backend/dist/src/plugins/prisma.js vendored Normal file
View File

@@ -0,0 +1,12 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.prisma = void 0;
const client_1 = require("@prisma/client");
const prisma = new client_1.PrismaClient();
exports.prisma = prisma;
exports.default = prisma;
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-07
*/

113
backend/dist/src/routes/games.js vendored Normal file
View File

@@ -0,0 +1,113 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const gamesController_1 = require("../controllers/gamesController");
const gameValidator_1 = require("../validators/gameValidator");
const zod_1 = require("zod");
async function gamesRoutes(app) {
/**
* GET /api/games
* Listar todos los juegos
*/
app.get('/games', async (request, reply) => {
const games = await gamesController_1.GamesController.listGames();
return reply.code(200).send(games);
});
/**
* GET /api/games/:id
* Obtener un juego por ID
*/
app.get('/games/:id', async (request, reply) => {
try {
const game = await gamesController_1.GamesController.getGameById(request.params.id);
return reply.code(200).send(game);
}
catch (error) {
if (error instanceof Error && error.message.includes('no encontrado')) {
return reply.code(404).send({
error: 'Juego no encontrado',
});
}
throw error;
}
});
/**
* POST /api/games
* Crear un nuevo juego
*/
app.post('/games', async (request, reply) => {
try {
// Validar entrada con Zod
const validated = gameValidator_1.createGameSchema.parse(request.body);
const game = await gamesController_1.GamesController.createGame(validated);
return reply.code(201).send(game);
}
catch (error) {
if (error instanceof zod_1.ZodError) {
return reply.code(400).send({
error: 'Validación fallida',
details: error.errors,
});
}
throw error;
}
});
/**
* PUT /api/games/:id
* Actualizar un juego existente
*/
app.put('/games/:id', async (request, reply) => {
try {
// Validar entrada con Zod
const validated = gameValidator_1.updateGameSchema.parse(request.body);
const game = await gamesController_1.GamesController.updateGame(request.params.id, validated);
return reply.code(200).send(game);
}
catch (error) {
if (error instanceof zod_1.ZodError) {
return reply.code(400).send({
error: 'Validación fallida',
details: error.errors,
});
}
if (error instanceof Error && error.message.includes('not found')) {
return reply.code(404).send({
error: 'Juego no encontrado',
});
}
throw error;
}
});
/**
* DELETE /api/games/:id
* Eliminar un juego
*/
app.delete('/games/:id', async (request, reply) => {
try {
await gamesController_1.GamesController.deleteGame(request.params.id);
return reply.code(204).send();
}
catch (error) {
if (error instanceof Error && error.message.includes('no encontrado')) {
return reply.code(404).send({
error: 'Juego no encontrado',
});
}
throw error;
}
});
/**
* GET /api/games/source/:source
* Listar juegos por fuente (rom, manual, igdb, rawg, etc.)
*/
app.get('/games/source/:source', async (request, reply) => {
const games = await gamesController_1.GamesController.listGamesBySource(request.params.source);
return reply.code(200).send(games);
});
}
exports.default = gamesRoutes;
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-03-18
* Actualizado para soportar fuente (source) en juegos
*/

11
backend/dist/src/routes/health.js vendored Normal file
View File

@@ -0,0 +1,11 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = healthRoutes;
async function healthRoutes(app) {
app.get('/health', async () => ({ status: 'ok' }));
}
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-07
*/

24
backend/dist/src/routes/import.js vendored Normal file
View File

@@ -0,0 +1,24 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = importRoutes;
const importRunner_1 = require("../jobs/importRunner");
const importService_1 = require("../services/importService");
async function importRoutes(app) {
app.post('/import/scan', async (request, reply) => {
const body = request.body;
// Encolar el job en background
setImmediate(() => {
importRunner_1.runner
.enqueue(async () => {
// no await here; background task. Pasamos el logger de Fastify para
// que los mensajes de advertencia se integren con el sistema de logs.
return (0, importService_1.importDirectory)({ dir: body?.dir, persist: body?.persist }, app.log);
})
.catch((err) => {
app.log.warn({ err }, 'Background import task failed');
});
});
// Responder inmediatamente
reply.code(202).send({ status: 'queued' });
});
}

77
backend/dist/src/routes/metadata.js vendored Normal file
View File

@@ -0,0 +1,77 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const metadataService = __importStar(require("../services/metadataService"));
const zod_1 = require("zod");
const zod_2 = require("zod");
// Esquema de validación para parámetros de búsqueda
const searchMetadataSchema = zod_1.z.object({
q: zod_1.z.string().min(1, 'El parámetro de búsqueda es requerido'),
platform: zod_1.z.string().optional(),
});
async function metadataRoutes(app) {
/**
* GET /api/metadata/search?q=query&platform=optional
* Buscar metadata de juegos
*/
app.get('/metadata/search', async (request, reply) => {
try {
// Validar parámetros de query con Zod
const validated = searchMetadataSchema.parse(request.query);
// Llamar a metadataService
const result = await metadataService.enrichGame({
title: validated.q,
platform: validated.platform,
});
// Si hay resultado, devolver como array; si no, devolver array vacío
return reply.code(200).send(result ? [result] : []);
}
catch (error) {
if (error instanceof zod_2.ZodError) {
return reply.code(400).send({
error: 'Parámetros de búsqueda inválidos',
details: error.errors,
});
}
throw error;
}
});
}
exports.default = metadataRoutes;
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -0,0 +1,166 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.listArchiveEntries = listArchiveEntries;
exports.streamArchiveEntry = streamArchiveEntry;
/**
* Servicio: archiveReader
*
* Lista el contenido de contenedores comunes (ZIP, 7z) sin necesidad de
* extraerlos completamente. Intenta usar la utilidad `7z` (7-Zip) con la
* opción `-slt` para obtener un listado con metadatos; si falla y el archivo
* es ZIP, intenta usar `unzip -l` como fallback.
*
* La función principal exportada es `listArchiveEntries(filePath, logger)` y
* devuelve un array de objetos `{ name, size }` con las entradas encontradas.
*
* Nota: este servicio depende de binarios del sistema (`7z`, `unzip`) cuando
* se trabaja con formatos comprimidos. En entornos de CI/producción debe
* asegurarse la presencia de dichas utilidades o los tests deben mockear
* las llamadas a `child_process.exec`.
*/
const path_1 = __importDefault(require("path"));
const child_process_1 = require("child_process");
async function listArchiveEntries(filePath, logger = console) {
const ext = path_1.default.extname(filePath).toLowerCase().replace(/^\./, '');
if (!['zip', '7z'].includes(ext))
return [];
const execCmd = (cmd) => new Promise((resolve, reject) => {
(0, child_process_1.exec)(cmd, (err, stdout, stderr) => {
if (err)
return reject(err);
resolve({ stdout: String(stdout), stderr: String(stderr) });
});
});
// Intentamos 7z -slt (salida técnica fácil de parsear)
const try7z = async () => {
const { stdout } = await execCmd(`7z l -slt ${JSON.stringify(filePath)}`);
const blocks = String(stdout).split(/\r?\n\r?\n/);
const entries = [];
for (const block of blocks) {
const lines = block.split(/\r?\n/);
const pathLine = lines.find((l) => l.startsWith('Path = '));
if (!pathLine)
continue;
const name = pathLine.replace(/^Path = /, '').trim();
const sizeLine = lines.find((l) => l.startsWith('Size = '));
const size = sizeLine ? parseInt(sizeLine.replace(/^Size = /, ''), 10) || 0 : 0;
entries.push({ name, size });
}
return entries;
};
try {
return await try7z();
}
catch (err) {
logger.warn?.({ err, filePath }, 'archiveReader: 7z failed, attempting fallback');
if (ext === 'zip') {
try {
const { stdout } = await execCmd(`unzip -l ${JSON.stringify(filePath)}`);
const lines = String(stdout).split(/\r?\n/);
const entries = [];
for (const line of lines) {
// línea típica: " 12345 path/to/file.bin"
const m = line.match(/^\s*(\d+)\s+(.+)$/);
if (m) {
const size = parseInt(m[1], 10);
const name = m[2].trim();
entries.push({ name, size });
}
}
return entries;
}
catch (err2) {
logger.warn?.({ err2, filePath }, 'archiveReader: unzip fallback failed');
return [];
}
}
return [];
}
}
async function streamArchiveEntry(filePath, entryPath, logger = console) {
const ext = path_1.default.extname(filePath).toLowerCase().replace(/^\./, '');
if (!['zip', '7z'].includes(ext))
return null;
const waitForStreamOrError = (proc) => new Promise((resolve) => {
let settled = false;
const onProcError = () => {
if (settled)
return;
settled = true;
resolve(null);
};
const onStdoutError = () => {
if (settled)
return;
settled = true;
resolve(null);
};
const onData = () => {
if (settled)
return;
settled = true;
try {
proc.removeListener('error', onProcError);
}
catch (e) { }
if (proc.stdout && proc.stdout.removeListener) {
try {
proc.stdout.removeListener('error', onStdoutError);
proc.stdout.removeListener('readable', onData);
proc.stdout.removeListener('data', onData);
}
catch (e) { }
}
resolve(proc.stdout);
};
proc.once('error', onProcError);
if (proc.stdout && proc.stdout.once) {
proc.stdout.once('error', onStdoutError);
proc.stdout.once('readable', onData);
proc.stdout.once('data', onData);
}
else {
// no stdout available
resolve(null);
}
proc.once('close', () => {
if (!settled) {
settled = true;
resolve(null);
}
});
});
// Try 7z first
try {
let proc;
try {
proc = (0, child_process_1.spawn)('7z', ['x', '-so', filePath, entryPath]);
}
catch (err) {
throw err;
}
const stream = await waitForStreamOrError(proc);
if (stream)
return stream;
}
catch (err) {
logger.warn?.({ err, filePath }, 'archiveReader: 7z spawn failed');
}
// Fallback for zip
if (ext === 'zip') {
try {
const proc2 = (0, child_process_1.spawn)('unzip', ['-p', filePath, entryPath]);
const stream2 = await waitForStreamOrError(proc2);
if (stream2)
return stream2;
}
catch (err2) {
logger.warn?.({ err2, filePath }, 'archiveReader: unzip spawn failed');
}
}
return null;
}
exports.default = { listArchiveEntries, streamArchiveEntry };

View File

@@ -0,0 +1,116 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.computeHashes = computeHashes;
exports.computeHashesFromStream = computeHashesFromStream;
/**
* Servicio: checksumService
*
* Calcula sumas y metadatos de un fichero de forma eficiente usando streams.
* Las funciones principales procesan el archivo en streaming para producir:
* - `md5` (hex)
* - `sha1` (hex)
* - `crc32` (hex, 8 caracteres)
* - `size` (bytes)
*
* `computeHashes(filePath)` devuelve un objeto con los valores anteriores y
* está pensado para usarse durante la importación/normalización de ROMs.
*/
const fs_1 = __importDefault(require("fs"));
const crypto_1 = require("crypto");
function makeCRCTable() {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
if (c & 1)
c = 0xedb88320 ^ (c >>> 1);
else
c = c >>> 1;
}
table[n] = c >>> 0;
}
return table;
}
const CRC_TABLE = makeCRCTable();
function updateCrc(crc, buf) {
let c = crc >>> 0;
for (let i = 0; i < buf.length; i++) {
c = (CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8)) >>> 0;
}
return c >>> 0;
}
async function computeHashes(filePath) {
return new Promise((resolve, reject) => {
const md5 = (0, crypto_1.createHash)('md5');
const sha1 = (0, crypto_1.createHash)('sha1');
let size = 0;
let crc = 0xffffffff >>> 0;
const rs = fs_1.default.createReadStream(filePath);
rs.on('error', (err) => reject(err));
rs.on('data', (chunk) => {
md5.update(chunk);
sha1.update(chunk);
size += chunk.length;
crc = updateCrc(crc, chunk);
});
rs.on('end', () => {
const md5sum = md5.digest('hex');
const sha1sum = sha1.digest('hex');
const final = (crc ^ 0xffffffff) >>> 0;
const crcHex = final.toString(16).padStart(8, '0').toLowerCase();
resolve({ size, md5: md5sum, sha1: sha1sum, crc32: crcHex });
});
});
}
async function computeHashesFromStream(rs) {
return new Promise((resolve, reject) => {
const md5 = (0, crypto_1.createHash)('md5');
const sha1 = (0, crypto_1.createHash)('sha1');
let size = 0;
let crc = 0xffffffff >>> 0;
let settled = false;
const cleanup = () => {
try {
rs.removeListener('error', onError);
rs.removeListener('data', onData);
rs.removeListener('end', onEnd);
rs.removeListener('close', onClose);
}
catch (e) { }
};
const finalize = () => {
if (settled)
return;
settled = true;
cleanup();
const md5sum = md5.digest('hex');
const sha1sum = sha1.digest('hex');
const final = (crc ^ 0xffffffff) >>> 0;
const crcHex = final.toString(16).padStart(8, '0').toLowerCase();
resolve({ size, md5: md5sum, sha1: sha1sum, crc32: crcHex });
};
const onError = (err) => {
if (settled)
return;
settled = true;
cleanup();
reject(err);
};
const onData = (chunk) => {
md5.update(chunk);
sha1.update(chunk);
size += chunk.length;
crc = updateCrc(crc, chunk);
};
const onEnd = () => finalize();
const onClose = () => finalize();
rs.on('error', onError);
rs.on('data', onData);
rs.on('end', onEnd);
rs.on('close', onClose);
});
}
exports.default = computeHashes;

View File

@@ -0,0 +1,72 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseDat = parseDat;
exports.verifyHashesAgainstDat = verifyHashesAgainstDat;
const fast_xml_parser_1 = require("fast-xml-parser");
function ensureArray(v) {
if (v === undefined || v === null)
return [];
return Array.isArray(v) ? v : [v];
}
function normalizeHex(v) {
if (!v)
return undefined;
return v.trim().toLowerCase();
}
function parseDat(xml) {
const parser = new fast_xml_parser_1.XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '',
trimValues: true,
});
const parsed = parser.parse(xml);
const datafile = parsed?.datafile ?? parsed;
const rawGames = ensureArray(datafile?.game);
const games = rawGames.map((g) => {
// game name may be an attribute or a child node
const nameAttr = g?.name ?? g?.['@_name'] ?? g?.$?.name;
const romNodes = ensureArray(g?.rom);
const roms = romNodes.map((r) => {
const rname = r?.name ?? r?.['@_name'] ?? r?.['@name'];
const sizeRaw = r?.size ?? r?.['@_size'];
const parsedSize = sizeRaw != null ? Number(sizeRaw) : undefined;
return {
name: String(rname ?? ''),
size: typeof parsedSize === 'number' && !Number.isNaN(parsedSize) ? parsedSize : undefined,
crc: normalizeHex(r?.crc ?? r?.['@_crc'] ?? r?.CRC ?? r?.['CRC']),
md5: normalizeHex(r?.md5 ?? r?.['@_md5'] ?? r?.MD5 ?? r?.['MD5']),
sha1: normalizeHex(r?.sha1 ?? r?.['@_sha1'] ?? r?.SHA1 ?? r?.['SHA1']),
};
});
return {
name: String(nameAttr ?? ''),
roms,
};
});
return { games };
}
function verifyHashesAgainstDat(datDb, hashes) {
const cmp = {
crc: normalizeHex(hashes.crc),
md5: normalizeHex(hashes.md5),
sha1: normalizeHex(hashes.sha1),
size: hashes.size,
};
for (const g of datDb.games) {
for (const r of g.roms) {
if (cmp.crc && r.crc && cmp.crc === r.crc) {
return { gameName: g.name, romName: r.name, matchedOn: 'crc' };
}
if (cmp.md5 && r.md5 && cmp.md5 === r.md5) {
return { gameName: g.name, romName: r.name, matchedOn: 'md5' };
}
if (cmp.sha1 && r.sha1 && cmp.sha1 === r.sha1) {
return { gameName: g.name, romName: r.name, matchedOn: 'sha1' };
}
if (cmp.size !== undefined && r.size !== undefined && Number(cmp.size) === Number(r.size)) {
return { gameName: g.name, romName: r.name, matchedOn: 'size' };
}
}
}
return null;
}

96
backend/dist/src/services/fsScanner.js vendored Normal file
View File

@@ -0,0 +1,96 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.scanDirectory = scanDirectory;
/**
* Servicio: fsScanner
*
* Este módulo proporciona la función `scanDirectory` que recorre recursivamente
* un directorio (ignorando archivos y carpetas que comienzan por `.`) y devuelve
* una lista de metadatos de ficheros encontrados. Cada entrada contiene el
* `path` completo, `filename`, `name`, `size`, `format` detectado por extensión
* y `isArchive` indicando si se trata de un contenedor (zip/7z/chd).
*
* Se usa desde el importService para listar las ROMs/archivos que deben ser
* procesados/hashed y persistidos.
*/
const path_1 = __importDefault(require("path"));
const fs_1 = require("fs");
const fileTypeDetector_1 = require("../lib/fileTypeDetector");
const archiveReader_1 = require("./archiveReader");
const DEFAULT_ARCHIVE_MAX_ENTRIES = 1000;
function getArchiveMaxEntries() {
const parsed = parseInt(process.env.ARCHIVE_MAX_ENTRIES ?? '', 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_ARCHIVE_MAX_ENTRIES;
}
async function scanDirectory(dirPath) {
const results = [];
let archiveEntriesAdded = 0;
async function walk(dir) {
const entries = await fs_1.promises.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith('.'))
continue; // ignore dotfiles
const full = path_1.default.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(full);
continue;
}
if (entry.isFile()) {
const stat = await fs_1.promises.stat(full);
const format = (0, fileTypeDetector_1.detectFormat)(entry.name);
const isArchive = ['zip', '7z', 'chd'].includes(format);
results.push({
path: full,
filename: entry.name,
name: entry.name,
size: stat.size,
format,
isArchive,
});
if (isArchive) {
try {
const entries = await (0, archiveReader_1.listArchiveEntries)(full);
const maxEntries = getArchiveMaxEntries();
for (const e of entries) {
if (archiveEntriesAdded >= maxEntries)
break;
if (!e || !e.name)
continue;
// Normalize entry path using posix rules and avoid traversal/absolute paths
const normalized = path_1.default.posix.normalize(e.name);
const parts = normalized.split('/').filter(Boolean);
if (parts.includes('..') || path_1.default.posix.isAbsolute(normalized))
continue;
results.push({
path: `${full}::${normalized}`,
containerPath: full,
entryPath: normalized,
filename: path_1.default.posix.basename(normalized),
name: normalized,
size: e.size,
format: (0, fileTypeDetector_1.detectFormat)(normalized),
isArchive: false,
isArchiveEntry: true,
});
archiveEntriesAdded++;
}
}
catch (err) {
// log for diagnostics but continue
try {
// eslint-disable-next-line no-console
console.debug('fsScanner: listArchiveEntries failed for', full, err);
}
catch (e) { }
}
}
}
}
}
await walk(dirPath);
return results;
}
exports.default = scanDirectory;

116
backend/dist/src/services/igdbClient.js vendored Normal file
View File

@@ -0,0 +1,116 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.searchGames = searchGames;
exports.getGameById = getGameById;
/**
* Cliente IGDB (Twitch OAuth)
* - `searchGames(query, platform?)`
* - `getGameById(id)`
*/
const undici_1 = require("undici");
const AUTH_URL = 'https://id.twitch.tv/oauth2/token';
const API_URL = 'https://api.igdb.com/v4';
let cachedToken = null;
async function getToken() {
if (cachedToken && Date.now() < cachedToken.expiresAt)
return cachedToken.token;
const clientId = process.env.IGDB_CLIENT_ID || process.env.TWITCH_CLIENT_ID;
const clientSecret = process.env.IGDB_CLIENT_SECRET || process.env.TWITCH_CLIENT_SECRET;
if (!clientId || !clientSecret)
return null;
try {
const params = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: 'client_credentials',
});
const res = await (0, undici_1.fetch)(`${AUTH_URL}?${params.toString()}`, { method: 'POST' });
if (!res.ok)
return null;
const json = await res.json();
const token = json.access_token;
const expires = Number(json.expires_in) || 0;
if (!token)
return null;
cachedToken = { token, expiresAt: Date.now() + Math.max(0, expires - 60) * 1000 };
return token;
}
catch (err) {
// eslint-disable-next-line no-console
console.debug('igdbClient.getToken error', err);
return null;
}
}
function mapIgdbHit(r) {
return {
id: r.id,
name: r.name,
slug: r.slug,
releaseDate: r.first_release_date
? new Date(r.first_release_date * 1000).toISOString()
: undefined,
genres: Array.isArray(r.genres) ? r.genres : undefined,
platforms: Array.isArray(r.platforms) ? r.platforms : undefined,
coverUrl: r.cover?.url ?? undefined,
source: 'igdb',
};
}
async function searchGames(query, _platform) {
const clientId = process.env.IGDB_CLIENT_ID || process.env.TWITCH_CLIENT_ID;
const token = await getToken();
if (!clientId || !token)
return [];
const headers = {
'Client-ID': clientId,
Authorization: `Bearer ${token}`,
Accept: 'application/json',
'Content-Type': 'text/plain',
};
const body = `search "${query}"; fields id,name,slug,first_release_date,genres,platforms,cover; limit 10;`;
try {
const res = await (0, undici_1.fetch)(`${API_URL}/games`, { method: 'POST', headers, body });
if (!res.ok)
return [];
const json = await res.json();
if (!Array.isArray(json))
return [];
return json.map(mapIgdbHit);
}
catch (err) {
// eslint-disable-next-line no-console
console.debug('igdbClient.searchGames error', err);
return [];
}
}
async function getGameById(id) {
const clientId = process.env.IGDB_CLIENT_ID || process.env.TWITCH_CLIENT_ID;
const token = await getToken();
if (!clientId || !token)
return null;
const headers = {
'Client-ID': clientId,
Authorization: `Bearer ${token}`,
Accept: 'application/json',
'Content-Type': 'text/plain',
};
const body = `where id = ${id}; fields id,name,slug,first_release_date,genres,platforms,cover; limit 1;`;
try {
const res = await (0, undici_1.fetch)(`${API_URL}/games`, { method: 'POST', headers, body });
if (!res.ok)
return null;
const json = await res.json();
if (!Array.isArray(json) || json.length === 0)
return null;
return mapIgdbHit(json[0]);
}
catch (err) {
// eslint-disable-next-line no-console
console.debug('igdbClient.getGameById error', err);
return null;
}
}
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -0,0 +1,132 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createSlug = createSlug;
exports.importDirectory = importDirectory;
/**
* Servicio: importService
*
* Orquesta el proceso de importación de juegos desde un directorio:
* 1. Lista archivos usando `scanDirectory`.
* 2. Calcula hashes y tamaño con `computeHashes` (streaming).
* 3. Normaliza el nombre a un `slug` y, si `persist` es true, crea/obtiene
* el `Game` correspondiente con source="rom".
*
* `importDirectory` devuelve un resumen con contadores `{ processed, createdCount, upserted }`.
*/
const path_1 = __importDefault(require("path"));
const fs_1 = require("fs");
const fsScanner_1 = require("./fsScanner");
const checksumService_1 = require("./checksumService");
const archiveReader_1 = require("./archiveReader");
const prisma_1 = __importDefault(require("../plugins/prisma"));
/**
* Crea un `slug` a partir de un nombre legible. Usado para generar slugs
* por defecto al crear entradas `Game` cuando no existe la coincidencia.
*/
function createSlug(name) {
return name
.toString()
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
async function importDirectory(options, logger = console) {
const providedDir = options?.dir;
const dir = providedDir ?? process.env.ROMS_PATH ?? path_1.default.join(process.cwd(), 'roms');
const persist = options?.persist !== undefined ? options.persist : true;
// Si no se pasó explícitamente la ruta, validamos que exista y sea un directorio
if (!providedDir) {
try {
const stat = await fs_1.promises.stat(dir);
if (!stat.isDirectory()) {
logger.warn?.({ dir }, 'importDirectory: ruta no es un directorio');
return { processed: 0, createdCount: 0, upserted: 0 };
}
}
catch (err) {
logger.warn?.({ err, dir }, 'importDirectory: ruta no accesible, abortando import');
return { processed: 0, createdCount: 0, upserted: 0 };
}
}
let files = [];
try {
files = await (0, fsScanner_1.scanDirectory)(dir);
}
catch (err) {
logger.warn?.({ err, dir }, 'importDirectory: error listando directorio');
return { processed: 0, createdCount: 0, upserted: 0 };
}
let processed = 0;
let createdCount = 0;
let upserted = 0;
for (const file of files) {
processed++;
try {
let hashes;
if (file.isArchiveEntry) {
const stream = await (0, archiveReader_1.streamArchiveEntry)(file.containerPath, file.entryPath, logger);
if (!stream) {
logger.warn?.({ file }, 'importDirectory: no se pudo extraer entrada del archive, saltando');
continue;
}
hashes = await (0, checksumService_1.computeHashesFromStream)(stream);
}
else {
hashes = await (0, checksumService_1.computeHashes)(file.path);
}
const checksum = hashes.md5;
const size = hashes.size;
const baseName = path_1.default.parse(file.filename).name;
const slug = createSlug(baseName);
if (persist) {
// Buscar si ya existe un juego con este checksum (source=rom)
let game = await prisma_1.default.game.findFirst({
where: {
source: 'rom',
romChecksum: checksum,
},
});
if (!game) {
// Crear nuevo juego con source="rom"
game = await prisma_1.default.game.create({
data: {
title: baseName,
slug: `${slug}-${Date.now()}`,
source: 'rom',
romPath: file.path,
romFilename: file.filename,
romSize: size,
romChecksum: checksum,
romFormat: file.format,
romHashes: JSON.stringify(hashes),
addedAt: new Date(),
lastSeenAt: new Date(),
},
});
createdCount++;
}
else {
// Actualizar lastSeenAt si ya existe
game = await prisma_1.default.game.update({
where: { id: game.id },
data: {
lastSeenAt: new Date(),
romHashes: JSON.stringify(hashes),
},
});
upserted++;
}
}
}
catch (err) {
logger.warn?.({ err, file }, 'importDirectory: error procesando fichero, se continúa con el siguiente');
continue;
}
}
return { processed, createdCount, upserted };
}
exports.default = importDirectory;

View File

@@ -0,0 +1,98 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.enrichGame = enrichGame;
/**
* metadataService
* - `enrichGame({ title, platform? })` -> intenta IGDB, RAWG, TheGamesDB
*/
const igdb = __importStar(require("./igdbClient"));
const rawg = __importStar(require("./rawgClient"));
const thegamesdb = __importStar(require("./thegamesdbClient"));
function normalize(hit) {
const base = {
source: hit.source ?? 'unknown',
externalIds: {},
title: hit.name,
slug: hit.slug,
releaseDate: hit.releaseDate,
genres: hit.genres,
coverUrl: hit.coverUrl,
};
if (hit.source === 'igdb' && typeof hit.id === 'number')
base.externalIds.igdb = hit.id;
if (hit.source === 'rawg' && typeof hit.id === 'number')
base.externalIds.rawg = hit.id;
if (hit.source === 'thegamesdb' && typeof hit.id === 'number')
base.externalIds.thegamesdb = hit.id;
return base;
}
async function enrichGame(opts) {
const title = opts?.title;
if (!title)
return null;
// Prefer IGDB (higher priority)
try {
const igdbHits = await igdb.searchGames(title, opts.platform);
if (igdbHits && igdbHits.length)
return normalize(igdbHits[0]);
}
catch (e) {
// ignore and continue
}
try {
const rawgHits = await rawg.searchGames(title);
if (rawgHits && rawgHits.length)
return normalize(rawgHits[0]);
}
catch (e) {
// ignore
}
try {
const tgHits = await thegamesdb.searchGames(title);
if (tgHits && tgHits.length)
return normalize(tgHits[0]);
}
catch (e) {
// ignore
}
return null;
}
exports.default = { enrichGame };
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

72
backend/dist/src/services/rawgClient.js vendored Normal file
View File

@@ -0,0 +1,72 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.searchGames = searchGames;
exports.getGameById = getGameById;
/**
* Cliente RAWG
* - `searchGames(query)`
* - `getGameById(id)`
*/
const undici_1 = require("undici");
const API_BASE = 'https://api.rawg.io/api';
async function searchGames(query) {
const key = process.env.RAWG_API_KEY;
if (!key)
return [];
try {
const url = `${API_BASE}/games?key=${encodeURIComponent(key)}&search=${encodeURIComponent(query)}&page_size=10`;
const res = await (0, undici_1.fetch)(url);
if (!res.ok)
return [];
const json = await res.json();
const hits = Array.isArray(json.results) ? json.results : [];
return hits.map((r) => ({
id: r.id,
name: r.name,
slug: r.slug,
releaseDate: r.released,
genres: Array.isArray(r.genres) ? r.genres.map((g) => g.name) : undefined,
platforms: r.platforms,
coverUrl: r.background_image ?? undefined,
source: 'rawg',
}));
}
catch (err) {
// eslint-disable-next-line no-console
console.debug('rawgClient.searchGames error', err);
return [];
}
}
async function getGameById(id) {
const key = process.env.RAWG_API_KEY;
if (!key)
return null;
try {
const url = `${API_BASE}/games/${encodeURIComponent(String(id))}?key=${encodeURIComponent(key)}`;
const res = await (0, undici_1.fetch)(url);
if (!res.ok)
return null;
const json = await res.json();
if (!json)
return null;
return {
id: json.id,
name: json.name,
slug: json.slug,
releaseDate: json.released,
genres: Array.isArray(json.genres) ? json.genres.map((g) => g.name) : undefined,
coverUrl: json.background_image ?? undefined,
source: 'rawg',
};
}
catch (err) {
// eslint-disable-next-line no-console
console.debug('rawgClient.getGameById error', err);
return null;
}
}
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -0,0 +1,85 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.searchGames = searchGames;
exports.getGameById = getGameById;
/**
* Cliente TheGamesDB (simple wrapper)
* - `searchGames(query)`
* - `getGameById(id)`
*/
const undici_1 = require("undici");
const API_BASE = 'https://api.thegamesdb.net';
async function searchGames(query) {
const key = process.env.THEGAMESDB_API_KEY;
if (!key)
return [];
try {
const url = `${API_BASE}/v1/Games/ByGameName?name=${encodeURIComponent(query)}`;
const res = await (0, undici_1.fetch)(url, { headers: { 'Api-Key': key } });
if (!res.ok)
return [];
const json = await res.json();
const games = json?.data?.games ?? {};
const baseUrl = json?.data?.base_url?.original ?? '';
const hits = [];
for (const gid of Object.keys(games)) {
const g = games[gid];
hits.push({
id: Number(gid),
name: g?.game?.title ?? g?.title ?? String(gid),
slug: g?.game?.slug ?? undefined,
releaseDate: g?.game?.release_date ?? undefined,
genres: Array.isArray(g?.game?.genres) ? g.game.genres.map((x) => x.name) : undefined,
coverUrl: g?.game?.images?.boxart?.[0]?.thumb
? `${baseUrl}${g.game.images.boxart[0].thumb}`
: undefined,
source: 'thegamesdb',
});
}
return hits;
}
catch (err) {
// eslint-disable-next-line no-console
console.debug('thegamesdbClient.searchGames error', err);
return [];
}
}
async function getGameById(id) {
const key = process.env.THEGAMESDB_API_KEY;
if (!key)
return null;
try {
const url = `${API_BASE}/v1/Games/ByGameID?id=${encodeURIComponent(String(id))}`;
const res = await (0, undici_1.fetch)(url, { headers: { 'Api-Key': key } });
if (!res.ok)
return null;
const json = await res.json();
const games = json?.data?.games ?? {};
const baseUrl = json?.data?.base_url?.original ?? '';
const firstKey = Object.keys(games)[0];
const g = games[firstKey];
if (!g)
return null;
return {
id: Number(firstKey),
name: g?.game?.title ?? g?.title ?? String(firstKey),
slug: g?.game?.slug ?? undefined,
releaseDate: g?.game?.release_date ?? undefined,
genres: Array.isArray(g?.game?.genres) ? g.game.genres.map((x) => x.name) : undefined,
coverUrl: g?.game?.images?.boxart?.[0]?.thumb
? `${baseUrl}${g.game.images.boxart[0].thumb}`
: undefined,
source: 'thegamesdb',
};
}
catch (err) {
// eslint-disable-next-line no-console
console.debug('thegamesdbClient.getGameById error', err);
return null;
}
}
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -0,0 +1,41 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.updateGameSchema = exports.createGameSchema = exports.GameSource = exports.GameCondition = void 0;
const zod_1 = require("zod");
// Enum para condiciones (Loose, CIB, New)
exports.GameCondition = zod_1.z.enum(['Loose', 'CIB', 'New']).optional();
// Enum para fuentes de juegos
exports.GameSource = zod_1.z.enum(['manual', 'rom', 'igdb', 'rawg', 'thegamesdb']).optional();
// Esquema de validación para crear un juego
exports.createGameSchema = zod_1.z.object({
title: zod_1.z.string().min(1, 'El título es requerido').trim(),
platformId: zod_1.z.string().optional(),
description: zod_1.z.string().optional().nullable(),
priceCents: zod_1.z.number().int().positive().optional(),
currency: zod_1.z.string().optional().default('USD'),
store: zod_1.z.string().optional(),
date: zod_1.z.string().optional(), // Acepta formato ISO (YYYY-MM-DD o ISO completo)
condition: exports.GameCondition,
source: zod_1.z.string().optional().default('manual'), // Fuente del juego
sourceId: zod_1.z.string().optional(), // ID en la fuente externa
});
// Esquema de validación para actualizar un juego (todos los campos son opcionales)
exports.updateGameSchema = zod_1.z
.object({
title: zod_1.z.string().min(1).trim().optional(),
platformId: zod_1.z.string().optional(),
description: zod_1.z.string().optional().nullable(),
priceCents: zod_1.z.number().int().positive().optional(),
currency: zod_1.z.string().optional(),
store: zod_1.z.string().optional(),
date: zod_1.z.string().optional(), // Acepta formato ISO (YYYY-MM-DD o ISO completo)
condition: exports.GameCondition,
source: zod_1.z.string().optional(), // Fuente del juego
sourceId: zod_1.z.string().optional(), // ID en la fuente externa
})
.strict();
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -0,0 +1,13 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.linkGameSchema = void 0;
const zod_1 = require("zod");
// Esquema para vincular un juego a un ROM
exports.linkGameSchema = zod_1.z.object({
gameId: zod_1.z.string().min(1, 'El ID del juego es requerido'),
});
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -0,0 +1,90 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const importRunner_1 = require("../../src/jobs/importRunner");
(0, vitest_1.describe)('jobs/importRunner', () => {
(0, vitest_1.it)('enqueue rechaza después de stop', async () => {
const runner = new importRunner_1.ImportRunner(1);
runner.start();
runner.stop();
await (0, vitest_1.expect)(runner.enqueue(() => 'x')).rejects.toThrow();
});
(0, vitest_1.it)('rechaza tareas en cola tras stop', async () => {
const r = new importRunner_1.ImportRunner(1);
// Primera tarea comienza inmediatamente
const t1 = r.enqueue(async () => {
await new Promise((res) => setTimeout(res, 50));
return 'ok1';
});
// Segunda tarea quedará en cola
const t2 = r.enqueue(async () => 'ok2');
// Parar el runner inmediatamente
r.stop();
await (0, vitest_1.expect)(t1).resolves.toBe('ok1');
await (0, vitest_1.expect)(t2).rejects.toThrow(/ImportRunner stopped/);
const s = r.getStatus();
(0, vitest_1.expect)(s.completed).toBeGreaterThanOrEqual(1);
});
(0, vitest_1.it)('completed incrementa en rechazo', async () => {
const runner = new importRunner_1.ImportRunner(1);
runner.start();
const p = runner.enqueue(() => Promise.reject(new Error('boom')));
await (0, vitest_1.expect)(p).rejects.toThrow('boom');
const status = runner.getStatus();
(0, vitest_1.expect)(status.completed).toBeGreaterThanOrEqual(1);
runner.stop();
});
(0, vitest_1.it)('enqueue resuelve con el resultado de la tarea', async () => {
const runner = new importRunner_1.ImportRunner(2);
runner.start();
const result = await runner.enqueue(async () => 'ok');
(0, vitest_1.expect)(result).toBe('ok');
const status = runner.getStatus();
(0, vitest_1.expect)(status.completed).toBe(1);
(0, vitest_1.expect)(status.running).toBe(0);
(0, vitest_1.expect)(status.queued).toBe(0);
(0, vitest_1.expect)(status.concurrency).toBe(2);
runner.stop();
});
(0, vitest_1.it)('respeta la concurrencia configurada', async () => {
const concurrency = 2;
const runner = new importRunner_1.ImportRunner(concurrency);
runner.start();
let active = 0;
const observed = [];
const makeTask = (delay) => async () => {
active++;
observed.push(active);
await new Promise((r) => setTimeout(r, delay));
active--;
return 'done';
};
const promises = [];
for (let i = 0; i < 5; i++) {
promises.push(runner.enqueue(makeTask(80)));
}
await Promise.all(promises);
(0, vitest_1.expect)(Math.max(...observed)).toBeLessThanOrEqual(concurrency);
runner.stop();
});
(0, vitest_1.it)('getStatus reporta queued, running, completed y concurrency', async () => {
const concurrency = 2;
const runner = new importRunner_1.ImportRunner(concurrency);
runner.start();
const p1 = runner.enqueue(() => new Promise((r) => setTimeout(() => r('a'), 60)));
const p2 = runner.enqueue(() => new Promise((r) => setTimeout(() => r('b'), 60)));
const p3 = runner.enqueue(() => new Promise((r) => setTimeout(() => r('c'), 60)));
// allow the runner to start tasks
await new Promise((r) => setImmediate(r));
const statusNow = runner.getStatus();
(0, vitest_1.expect)(statusNow.concurrency).toBe(concurrency);
(0, vitest_1.expect)(statusNow.running).toBeLessThanOrEqual(concurrency);
(0, vitest_1.expect)(statusNow.queued).toBeGreaterThanOrEqual(0);
await Promise.all([p1, p2, p3]);
const statusAfter = runner.getStatus();
(0, vitest_1.expect)(statusAfter.queued).toBe(0);
(0, vitest_1.expect)(statusAfter.running).toBe(0);
(0, vitest_1.expect)(statusAfter.completed).toBe(3);
runner.stop();
});
});

89
backend/dist/tests/models/game.spec.js vendored Normal file
View File

@@ -0,0 +1,89 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const fs_1 = __importDefault(require("fs"));
const os_1 = __importDefault(require("os"));
const path_1 = __importDefault(require("path"));
const child_process_1 = require("child_process");
const vitest_1 = require("vitest");
// Import PrismaClient dynamically after running `prisma generate`
// to allow the test setup to run `prisma generate`/`prisma migrate` first.
// Nota: Estos tests siguen TDD. Al principio deben FALLAR hasta que se creen migraciones.
(0, vitest_1.describe)('Prisma / Game model', () => {
const tmpDir = os_1.default.tmpdir();
const dbFile = path_1.default.join(tmpDir, `quasar-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
const databaseUrl = `file:${dbFile}`;
let prisma;
(0, vitest_1.beforeAll)(async () => {
// Asegurarse de que la DB de prueba no exista antes de empezar
try {
fs_1.default.unlinkSync(dbFile);
}
catch (e) {
/* ignore */
}
// Apuntar Prisma a la DB temporal
process.env.DATABASE_URL = databaseUrl;
// Ejecutar migraciones contra la DB de prueba
// Esto fallará si no hay migraciones: esperado en la fase TDD inicial
(0, child_process_1.execSync)('yarn prisma migrate deploy --schema=./prisma/schema.prisma', {
stdio: 'inherit',
cwd: path_1.default.resolve(__dirname, '..', '..'),
});
// Intentar requerir el cliente generado; si no existe, intentar generarlo (fallback)
let GeneratedPrismaClient;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
GeneratedPrismaClient = require('@prisma/client').PrismaClient;
}
catch (e) {
try {
(0, child_process_1.execSync)('yarn prisma generate --schema=./prisma/schema.prisma', {
stdio: 'inherit',
cwd: path_1.default.resolve(__dirname, '..', '..'),
});
// eslint-disable-next-line @typescript-eslint/no-var-requires
GeneratedPrismaClient = require('@prisma/client').PrismaClient;
}
catch (err) {
// Si generation falla (por ejemplo PnP), reintentar require para mostrar mejor error
// eslint-disable-next-line @typescript-eslint/no-var-requires
GeneratedPrismaClient = require('@prisma/client').PrismaClient;
}
}
prisma = new GeneratedPrismaClient();
await prisma.$connect();
});
(0, vitest_1.afterAll)(async () => {
if (prisma) {
await prisma.$disconnect();
}
try {
fs_1.default.unlinkSync(dbFile);
}
catch (e) {
/* ignore */
}
});
(0, vitest_1.it)('can create a Game and read title/slug', async () => {
const created = await prisma.game.create({ data: { title: 'Test Game', slug: 'test-game' } });
const found = await prisma.game.findUnique({ where: { id: created.id } });
(0, vitest_1.expect)(found).toBeTruthy();
(0, vitest_1.expect)(found?.title).toBe('Test Game');
(0, vitest_1.expect)(found?.slug).toBe('test-game');
});
(0, vitest_1.it)('enforces unique slug constraint', async () => {
const slug = `unique-${Date.now()}`;
await prisma.game.create({ data: { title: 'G1', slug } });
let threw = false;
try {
await prisma.game.create({ data: { title: 'G2', slug } });
}
catch (err) {
threw = true;
}
(0, vitest_1.expect)(threw).toBe(true);
});
});

228
backend/dist/tests/routes/games.spec.js vendored Normal file
View File

@@ -0,0 +1,228 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const app_1 = require("../../src/app");
const prisma_1 = require("../../src/plugins/prisma");
(0, vitest_1.describe)('Games API', () => {
let app;
(0, vitest_1.beforeEach)(async () => {
app = (0, app_1.buildApp)();
await app.ready();
// Limpiar base de datos antes de cada test
// Orden importante: relaciones de FK primero
await prisma_1.prisma.purchase.deleteMany();
await prisma_1.prisma.gamePlatform.deleteMany();
await prisma_1.prisma.artwork.deleteMany();
await prisma_1.prisma.priceHistory.deleteMany();
await prisma_1.prisma.game.deleteMany();
await prisma_1.prisma.platform.deleteMany();
});
(0, vitest_1.afterEach)(async () => {
await app.close();
});
(0, vitest_1.describe)('GET /api/games', () => {
(0, vitest_1.it)('debería devolver una lista vacía cuando no hay juegos', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/games',
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
(0, vitest_1.expect)(res.json()).toEqual([]);
});
(0, vitest_1.it)('debería devolver una lista de juegos con todas sus propiedades', async () => {
// Crear un juego de prueba
const platform = await prisma_1.prisma.platform.create({
data: { name: 'Nintendo', slug: 'nintendo' },
});
const game = await prisma_1.prisma.game.create({
data: {
title: 'The Legend of Zelda',
slug: 'legend-of-zelda',
description: 'Un videojuego clásico',
source: 'manual',
gamePlatforms: {
create: {
platformId: platform.id,
},
},
purchases: {
create: {
priceCents: 5000,
currency: 'USD',
store: 'eBay',
date: new Date('2025-01-15'),
},
},
},
include: {
gamePlatforms: {
include: {
platform: true,
},
},
purchases: true,
},
});
const res = await app.inject({
method: 'GET',
url: '/api/games',
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
const body = res.json();
(0, vitest_1.expect)(Array.isArray(body)).toBe(true);
(0, vitest_1.expect)(body.length).toBe(1);
(0, vitest_1.expect)(body[0]).toHaveProperty('id');
(0, vitest_1.expect)(body[0]).toHaveProperty('title');
});
});
(0, vitest_1.describe)('POST /api/games', () => {
(0, vitest_1.it)('debería crear un juego válido con todos los campos', async () => {
// Crear plataforma primero
const platform = await prisma_1.prisma.platform.create({
data: { name: 'Nintendo 64', slug: 'n64' },
});
const payload = {
title: 'Super Mario 64',
platformId: platform.id,
description: 'Notas sobre el juego',
priceCents: 15000,
currency: 'USD',
store: 'Local Shop',
date: '2025-01-20',
condition: 'CIB',
};
const res = await app.inject({
method: 'POST',
url: '/api/games',
payload,
});
(0, vitest_1.expect)(res.statusCode).toBe(201);
const body = res.json();
(0, vitest_1.expect)(body).toHaveProperty('id');
(0, vitest_1.expect)(body.title).toBe('Super Mario 64');
(0, vitest_1.expect)(body.description).toBe('Notas sobre el juego');
});
(0, vitest_1.it)('debería fallar si falta el título (requerido)', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/games',
payload: {
platformId: 'non-existing-id',
priceCents: 10000,
},
});
(0, vitest_1.expect)(res.statusCode).toBe(400);
});
(0, vitest_1.it)('debería fallar si el título está vacío', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/games',
payload: {
title: '',
platformId: 'some-id',
},
});
(0, vitest_1.expect)(res.statusCode).toBe(400);
});
(0, vitest_1.it)('debería crear un juego con solo los campos requeridos', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/games',
payload: {
title: 'Game Title Only',
},
});
(0, vitest_1.expect)(res.statusCode).toBe(201);
const body = res.json();
(0, vitest_1.expect)(body).toHaveProperty('id');
(0, vitest_1.expect)(body.title).toBe('Game Title Only');
});
});
(0, vitest_1.describe)('PUT /api/games/:id', () => {
(0, vitest_1.it)('debería actualizar un juego existente', async () => {
const game = await prisma_1.prisma.game.create({
data: {
title: 'Original Title',
slug: 'original-title',
source: 'manual',
},
});
const res = await app.inject({
method: 'PUT',
url: `/api/games/${game.id}`,
payload: {
title: 'Updated Title',
description: 'Updated description',
},
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
const body = res.json();
(0, vitest_1.expect)(body.title).toBe('Updated Title');
(0, vitest_1.expect)(body.description).toBe('Updated description');
});
(0, vitest_1.it)('debería devolver 404 si el juego no existe', async () => {
const res = await app.inject({
method: 'PUT',
url: '/api/games/non-existing-id',
payload: {
title: 'Some Title',
},
});
(0, vitest_1.expect)(res.statusCode).toBe(404);
});
(0, vitest_1.it)('debería permitir actualización parcial', async () => {
const game = await prisma_1.prisma.game.create({
data: {
title: 'Original Title',
slug: 'original',
description: 'Original description',
source: 'manual',
},
});
const res = await app.inject({
method: 'PUT',
url: `/api/games/${game.id}`,
payload: {
description: 'New description only',
},
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
const body = res.json();
(0, vitest_1.expect)(body.title).toBe('Original Title'); // No cambió
(0, vitest_1.expect)(body.description).toBe('New description only'); // Cambió
});
});
(0, vitest_1.describe)('DELETE /api/games/:id', () => {
(0, vitest_1.it)('debería eliminar un juego existente', async () => {
const game = await prisma_1.prisma.game.create({
data: {
title: 'Game to Delete',
slug: 'game-to-delete',
source: 'manual',
},
});
const res = await app.inject({
method: 'DELETE',
url: `/api/games/${game.id}`,
});
(0, vitest_1.expect)(res.statusCode).toBe(204);
// Verificar que el juego fue eliminado
const deletedGame = await prisma_1.prisma.game.findUnique({
where: { id: game.id },
});
(0, vitest_1.expect)(deletedGame).toBeNull();
});
(0, vitest_1.it)('debería devolver 404 si el juego no existe', async () => {
const res = await app.inject({
method: 'DELETE',
url: '/api/games/non-existing-id',
});
(0, vitest_1.expect)(res.statusCode).toBe(404);
});
});
});
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -0,0 +1,17 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const app_1 = require("../../src/app");
(0, vitest_1.describe)('routes/import', () => {
(0, vitest_1.it)('POST /api/import/scan devuelve 202 o 200', async () => {
const app = (0, app_1.buildApp)();
await app.ready();
const res = await app.inject({
method: 'POST',
url: '/api/import/scan',
payload: { persist: false },
});
(0, vitest_1.expect)([200, 202]).toContain(res.statusCode);
await app.close();
});
});

View File

@@ -0,0 +1,117 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const app_1 = require("../../src/app");
const metadataService = __importStar(require("../../src/services/metadataService"));
(0, vitest_1.describe)('Metadata API', () => {
let app;
(0, vitest_1.beforeEach)(async () => {
app = (0, app_1.buildApp)();
await app.ready();
});
(0, vitest_1.afterEach)(async () => {
await app.close();
vitest_1.vi.restoreAllMocks();
});
(0, vitest_1.describe)('GET /api/metadata/search', () => {
(0, vitest_1.it)('debería devolver resultados cuando se busca un juego existente', async () => {
const mockResults = [
{
source: 'igdb',
externalIds: { igdb: 1 },
title: 'The Legend of Zelda',
slug: 'the-legend-of-zelda',
releaseDate: '1986-02-21',
genres: ['Adventure'],
coverUrl: 'https://example.com/cover.jpg',
},
];
vitest_1.vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(mockResults[0]);
const res = await app.inject({
method: 'GET',
url: '/api/metadata/search?q=zelda',
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
const body = res.json();
(0, vitest_1.expect)(Array.isArray(body)).toBe(true);
(0, vitest_1.expect)(body.length).toBeGreaterThan(0);
(0, vitest_1.expect)(body[0].title).toContain('Zelda');
});
(0, vitest_1.it)('debería devolver lista vacía cuando no hay resultados', async () => {
vitest_1.vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(null);
const res = await app.inject({
method: 'GET',
url: '/api/metadata/search?q=nonexistentgame12345',
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
const body = res.json();
(0, vitest_1.expect)(Array.isArray(body)).toBe(true);
(0, vitest_1.expect)(body.length).toBe(0);
});
(0, vitest_1.it)('debería devolver 400 si falta el parámetro query', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/metadata/search',
});
(0, vitest_1.expect)(res.statusCode).toBe(400);
(0, vitest_1.expect)(res.json()).toHaveProperty('error');
});
(0, vitest_1.it)('debería devolver 400 si el parámetro query está vacío', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/metadata/search?q=',
});
(0, vitest_1.expect)(res.statusCode).toBe(400);
});
(0, vitest_1.it)('debería pasar el parámetro platform a enrichGame si se proporciona', async () => {
const enrichSpy = vitest_1.vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(null);
const res = await app.inject({
method: 'GET',
url: '/api/metadata/search?q=mario&platform=Nintendo%2064',
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
(0, vitest_1.expect)(enrichSpy).toHaveBeenCalledWith({
title: 'mario',
platform: 'Nintendo 64',
});
});
});
});
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

255
backend/dist/tests/routes/roms.spec.js vendored Normal file
View File

@@ -0,0 +1,255 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const app_1 = require("../../src/app");
const prisma_1 = require("../../src/plugins/prisma");
(0, vitest_1.describe)('ROMs API', () => {
let app;
(0, vitest_1.beforeEach)(async () => {
app = (0, app_1.buildApp)();
await app.ready();
// Limpiar base de datos antes de cada test (eliminar ROMs primero por foreign key)
await prisma_1.prisma.romFile.deleteMany();
await prisma_1.prisma.gamePlatform.deleteMany();
await prisma_1.prisma.purchase.deleteMany();
await prisma_1.prisma.artwork.deleteMany();
await prisma_1.prisma.priceHistory.deleteMany();
await prisma_1.prisma.game.deleteMany();
});
(0, vitest_1.afterEach)(async () => {
await app.close();
});
(0, vitest_1.describe)('GET /api/roms', () => {
(0, vitest_1.it)('debería devolver una lista vacía cuando no hay ROMs', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/roms',
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
(0, vitest_1.expect)(res.json()).toEqual([]);
});
(0, vitest_1.it)('debería devolver una lista de ROMs con sus propiedades', async () => {
// Crear un ROM de prueba
const rom = await prisma_1.prisma.romFile.create({
data: {
path: '/roms/games/',
filename: 'game.zip',
checksum: 'abc123def456',
size: 1024,
format: 'zip',
},
});
const res = await app.inject({
method: 'GET',
url: '/api/roms',
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
const body = res.json();
(0, vitest_1.expect)(Array.isArray(body)).toBe(true);
(0, vitest_1.expect)(body.length).toBe(1);
(0, vitest_1.expect)(body[0].id).toBe(rom.id);
(0, vitest_1.expect)(body[0].filename).toBe('game.zip');
});
(0, vitest_1.it)('debería incluir información del juego asociado', async () => {
const game = await prisma_1.prisma.game.create({
data: {
title: 'Test Game',
slug: 'test-game',
},
});
const rom = await prisma_1.prisma.romFile.create({
data: {
path: '/roms/',
filename: 'test-with-game.zip',
checksum: 'checksum-game-123',
size: 2048,
format: 'zip',
gameId: game.id,
},
});
const res = await app.inject({
method: 'GET',
url: '/api/roms',
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
const body = res.json();
// Buscar el ROM que creamos por checksum
const createdRom = body.find((r) => r.checksum === 'checksum-game-123');
(0, vitest_1.expect)(createdRom).toBeDefined();
(0, vitest_1.expect)(createdRom.game).toBeDefined();
(0, vitest_1.expect)(createdRom.game.title).toBe('Test Game');
});
});
(0, vitest_1.describe)('GET /api/roms/:id', () => {
(0, vitest_1.it)('debería retornar un ROM existente', async () => {
const rom = await prisma_1.prisma.romFile.create({
data: {
path: '/roms/',
filename: 'game1.zip',
checksum: 'checksum1',
size: 1024,
format: 'zip',
},
});
const res = await app.inject({
method: 'GET',
url: `/api/roms/${rom.id}`,
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
const body = res.json();
(0, vitest_1.expect)(body.id).toBe(rom.id);
(0, vitest_1.expect)(body.filename).toBe('game1.zip');
});
(0, vitest_1.it)('debería retornar 404 si el ROM no existe', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/roms/non-existing-id',
});
(0, vitest_1.expect)(res.statusCode).toBe(404);
(0, vitest_1.expect)(res.json()).toHaveProperty('error');
});
(0, vitest_1.it)('debería incluir el juego asociado al ROM', async () => {
const game = await prisma_1.prisma.game.create({
data: {
title: 'Zelda',
slug: 'zelda',
},
});
const rom = await prisma_1.prisma.romFile.create({
data: {
path: '/roms/',
filename: 'zelda.zip',
checksum: 'checksum2',
size: 2048,
format: 'zip',
gameId: game.id,
},
});
const res = await app.inject({
method: 'GET',
url: `/api/roms/${rom.id}`,
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
const body = res.json();
(0, vitest_1.expect)(body.game).toBeDefined();
(0, vitest_1.expect)(body.game.title).toBe('Zelda');
});
});
(0, vitest_1.describe)('PUT /api/roms/:id/game', () => {
(0, vitest_1.it)('debería vincular un juego a un ROM existente', async () => {
const game = await prisma_1.prisma.game.create({
data: {
title: 'Mario',
slug: 'mario',
},
});
const rom = await prisma_1.prisma.romFile.create({
data: {
path: '/roms/',
filename: 'mario.zip',
checksum: 'checksum3',
size: 512,
format: 'zip',
},
});
const res = await app.inject({
method: 'PUT',
url: `/api/roms/${rom.id}/game`,
payload: {
gameId: game.id,
},
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
const body = res.json();
(0, vitest_1.expect)(body.gameId).toBe(game.id);
(0, vitest_1.expect)(body.game.title).toBe('Mario');
});
(0, vitest_1.it)('debería devolver 400 si el gameId es inválido', async () => {
const rom = await prisma_1.prisma.romFile.create({
data: {
path: '/roms/',
filename: 'game.zip',
checksum: 'checksum4',
size: 1024,
format: 'zip',
},
});
const res = await app.inject({
method: 'PUT',
url: `/api/roms/${rom.id}/game`,
payload: {
gameId: 'invalid-game-id',
},
});
(0, vitest_1.expect)(res.statusCode).toBe(400);
});
(0, vitest_1.it)('debería devolver 404 si el ROM no existe', async () => {
const game = await prisma_1.prisma.game.create({
data: {
title: 'Test',
slug: 'test',
},
});
const res = await app.inject({
method: 'PUT',
url: '/api/roms/non-existing-id/game',
payload: {
gameId: game.id,
},
});
(0, vitest_1.expect)(res.statusCode).toBe(404);
});
(0, vitest_1.it)('debería devolver 400 si falta gameId', async () => {
const rom = await prisma_1.prisma.romFile.create({
data: {
path: '/roms/',
filename: 'game.zip',
checksum: 'checksum5',
size: 1024,
format: 'zip',
},
});
const res = await app.inject({
method: 'PUT',
url: `/api/roms/${rom.id}/game`,
payload: {},
});
(0, vitest_1.expect)(res.statusCode).toBe(400);
});
});
(0, vitest_1.describe)('DELETE /api/roms/:id', () => {
(0, vitest_1.it)('debería eliminar un ROM existente', async () => {
const rom = await prisma_1.prisma.romFile.create({
data: {
path: '/roms/',
filename: 'delete-me.zip',
checksum: 'checksum6',
size: 1024,
format: 'zip',
},
});
const res = await app.inject({
method: 'DELETE',
url: `/api/roms/${rom.id}`,
});
(0, vitest_1.expect)(res.statusCode).toBe(204);
// Verificar que el ROM fue eliminado
const deletedRom = await prisma_1.prisma.romFile.findUnique({
where: { id: rom.id },
});
(0, vitest_1.expect)(deletedRom).toBeNull();
});
(0, vitest_1.it)('debería devolver 404 si el ROM no existe', async () => {
const res = await app.inject({
method: 'DELETE',
url: '/api/roms/non-existing-id',
});
(0, vitest_1.expect)(res.statusCode).toBe(404);
});
});
});
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

19
backend/dist/tests/server.spec.js vendored Normal file
View File

@@ -0,0 +1,19 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const app_1 = require("../src/app");
(0, vitest_1.describe)('Server', () => {
(0, vitest_1.it)('GET /api/health devuelve 200 y { status: "ok" }', async () => {
const app = (0, app_1.buildApp)();
await app.ready();
const res = await app.inject({ method: 'GET', url: '/api/health' });
(0, vitest_1.expect)(res.statusCode).toBe(200);
(0, vitest_1.expect)(res.json()).toEqual({ status: 'ok' });
await app.close();
});
});
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-07
*/

View File

@@ -0,0 +1,55 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const fs_1 = require("fs");
const path_1 = __importDefault(require("path"));
const child_process_1 = require("child_process");
const checksumService_1 = require("../../src/services/checksumService");
const archiveReader_1 = require("../../src/services/archiveReader");
const crypto_1 = require("crypto");
function hasBinary(bin) {
try {
(0, child_process_1.execSync)(`which ${bin}`, { stdio: 'ignore' });
return true;
}
catch (e) {
return false;
}
}
const wantIntegration = process.env.INTEGRATION === '1';
const canCreate = hasBinary('7z') || hasBinary('zip');
if (!wantIntegration) {
vitest_1.test.skip('archiveReader integration tests require INTEGRATION=1', () => { });
}
else if (!canCreate) {
vitest_1.test.skip('archiveReader integration tests skipped: no archive creation tool (7z or zip) available', () => { });
}
else {
(0, vitest_1.test)('reads entry from zip using system tools', async () => {
const tmpDir = await fs_1.promises.mkdtemp(path_1.default.join(process.cwd(), 'tmp-arc-'));
const inner = path_1.default.join(tmpDir, 'game.rom');
const content = 'QUASAR-INTEGRATION-TEST';
await fs_1.promises.writeFile(inner, content);
const archivePath = path_1.default.join(tmpDir, 'simple.zip');
// create zip using available tool
if (hasBinary('7z')) {
(0, child_process_1.execSync)(`7z a -tzip ${JSON.stringify(archivePath)} ${JSON.stringify(inner)}`, {
stdio: 'ignore',
});
}
else {
(0, child_process_1.execSync)(`zip -j ${JSON.stringify(archivePath)} ${JSON.stringify(inner)}`, {
stdio: 'ignore',
});
}
const stream = await (0, archiveReader_1.streamArchiveEntry)(archivePath, path_1.default.basename(inner));
(0, vitest_1.expect)(stream).not.toBeNull();
const hashes = await (0, checksumService_1.computeHashesFromStream)(stream);
const expectedMd5 = (0, crypto_1.createHash)('md5').update(content).digest('hex');
(0, vitest_1.expect)(hashes.md5).toBe(expectedMd5);
await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
});
}

View File

@@ -0,0 +1,75 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
// Mockeamos el módulo `child_process` para controlar las llamadas a `exec`.
vitest_1.vi.mock('child_process', () => ({ exec: vitest_1.vi.fn() }));
const child_process = __importStar(require("child_process"));
const archiveReader_1 = require("../../src/services/archiveReader");
(0, vitest_1.describe)('services/archiveReader', () => {
(0, vitest_1.it)('lista entradas usando 7z -slt', async () => {
const stdout = `Path = file1.txt\nSize = 123\nPacked Size = 0\n\nPath = dir/file2.bin\nSize = 456\nPacked Size = 0\n`;
child_process.exec.mockImplementation((cmd, cb) => {
cb(null, stdout, '');
return {};
});
const entries = await (0, archiveReader_1.listArchiveEntries)('/roms/archive.7z', console);
(0, vitest_1.expect)(entries.length).toBe(2);
(0, vitest_1.expect)(entries[0].name).toBe('file1.txt');
(0, vitest_1.expect)(entries[0].size).toBe(123);
child_process.exec.mockRestore?.();
});
(0, vitest_1.it)('usa unzip como fallback para zip cuando 7z falla', async () => {
child_process.exec
.mockImplementationOnce((cmd, cb) => {
// simular fallo de 7z
cb(new Error('7z not found'), '', '');
return {};
})
.mockImplementationOnce((cmd, cb) => {
// salida simulada de unzip -l
cb(null, ' 123 file1.txt\n 456 file2.bin\n', '');
return {};
});
const entries = await (0, archiveReader_1.listArchiveEntries)('/roms/archive.zip', console);
(0, vitest_1.expect)(entries.length).toBe(2);
(0, vitest_1.expect)(entries[0].name).toBe('file1.txt');
child_process.exec.mockRestore?.();
});
(0, vitest_1.it)('retorna vacío para formatos no soportados', async () => {
const entries = await (0, archiveReader_1.listArchiveEntries)('/roms/simple.bin');
(0, vitest_1.expect)(entries).toEqual([]);
});
});

View File

@@ -0,0 +1,89 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const stream_1 = require("stream");
const events_1 = require("events");
vitest_1.vi.mock('child_process', () => ({ spawn: vitest_1.vi.fn() }));
const child_process = __importStar(require("child_process"));
const archiveReader_1 = require("../../src/services/archiveReader");
(0, vitest_1.afterEach)(() => {
vitest_1.vi.restoreAllMocks();
});
(0, vitest_1.describe)('services/archiveReader streamArchiveEntry', () => {
(0, vitest_1.it)('streams entry using 7z stdout', async () => {
const pass = new stream_1.PassThrough();
const proc = new events_1.EventEmitter();
proc.stdout = pass;
child_process.spawn.mockImplementation(() => proc);
// Emular producción de datos de forma asíncrona
setImmediate(() => {
pass.write(Buffer.from('content-from-7z'));
pass.end();
});
const stream = await (0, archiveReader_1.streamArchiveEntry)('/roms/archive.7z', 'path/file.txt');
(0, vitest_1.expect)(stream).not.toBeNull();
const chunks = [];
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk));
}
(0, vitest_1.expect)(Buffer.concat(chunks).toString()).toBe('content-from-7z');
});
(0, vitest_1.it)('falls back to unzip -p when 7z throws', async () => {
const pass = new stream_1.PassThrough();
const proc2 = new events_1.EventEmitter();
proc2.stdout = pass;
child_process.spawn
.mockImplementationOnce(() => {
throw new Error('spawn ENOENT');
})
.mockImplementationOnce(() => proc2);
setImmediate(() => {
pass.write(Buffer.from('fallback-content'));
pass.end();
});
const stream = await (0, archiveReader_1.streamArchiveEntry)('/roms/archive.zip', 'file.dat');
(0, vitest_1.expect)(stream).not.toBeNull();
const chunks = [];
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk));
}
(0, vitest_1.expect)(Buffer.concat(chunks).toString()).toBe('fallback-content');
});
(0, vitest_1.it)('returns null for unsupported formats', async () => {
const res = await (0, archiveReader_1.streamArchiveEntry)('/roms/archive.bin', 'entry');
(0, vitest_1.expect)(res).toBeNull();
});
});

View File

@@ -0,0 +1,23 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const path_1 = __importDefault(require("path"));
const checksumService_1 = require("../../src/services/checksumService");
const fixturesDir = path_1.default.join(__dirname, '..', 'fixtures');
const simpleRom = path_1.default.join(fixturesDir, 'simple-rom.bin');
(0, vitest_1.describe)('services/checksumService', () => {
(0, vitest_1.it)('exporta computeHashes', () => {
(0, vitest_1.expect)(typeof checksumService_1.computeHashes).toBe('function');
});
(0, vitest_1.it)('calcula hashes', async () => {
const meta = await (0, checksumService_1.computeHashes)(simpleRom);
(0, vitest_1.expect)(meta).toBeDefined();
(0, vitest_1.expect)(meta.size).toBeGreaterThan(0);
(0, vitest_1.expect)(meta.md5).toBeDefined();
(0, vitest_1.expect)(meta.sha1).toBeDefined();
(0, vitest_1.expect)(meta.crc32).toBeDefined();
});
});

View File

@@ -0,0 +1,23 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const stream_1 = require("stream");
const promises_1 = __importDefault(require("fs/promises"));
const path_1 = __importDefault(require("path"));
const checksumService_1 = require("../../src/services/checksumService");
(0, vitest_1.describe)('services/checksumService (stream)', () => {
(0, vitest_1.it)('computeHashesFromStream produces same result as computeHashes(file)', async () => {
const data = Buffer.from('quasar-stream-test');
const tmpDir = await promises_1.default.mkdtemp(path_1.default.join(process.cwd(), 'tmp-checksum-'));
const tmpFile = path_1.default.join(tmpDir, 'test.bin');
await promises_1.default.writeFile(tmpFile, data);
const expected = await (0, checksumService_1.computeHashes)(tmpFile);
const rs = stream_1.Readable.from([data]);
const actual = await (0, checksumService_1.computeHashesFromStream)(rs);
(0, vitest_1.expect)(actual).toEqual(expected);
await promises_1.default.rm(tmpDir, { recursive: true, force: true });
});
});

View File

@@ -0,0 +1,64 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const datVerifier_1 = require("../../src/services/datVerifier");
const FIXTURE = path_1.default.resolve('tests/fixtures/sample.dat.xml');
(0, vitest_1.describe)('services/datVerifier', () => {
(0, vitest_1.it)('parseDat parses simple DAT XML', () => {
const xml = fs_1.default.readFileSync(FIXTURE, 'utf8');
const dat = (0, datVerifier_1.parseDat)(xml);
(0, vitest_1.expect)(dat).toBeTruthy();
(0, vitest_1.expect)(Array.isArray(dat.games)).toBe(true);
(0, vitest_1.expect)(dat.games.length).toBe(2);
const g0 = dat.games[0];
(0, vitest_1.expect)(g0.name).toBe('Game Alpha');
(0, vitest_1.expect)(g0.roms.length).toBeGreaterThan(0);
(0, vitest_1.expect)(g0.roms[0].name).toBe('alpha1.bin');
(0, vitest_1.expect)(g0.roms[0].crc).toBeDefined();
(0, vitest_1.expect)(g0.roms[0].md5).toBeDefined();
const g1 = dat.games[1];
(0, vitest_1.expect)(g1.name).toBe('Game Beta');
(0, vitest_1.expect)(g1.roms.some((r) => r.name === 'beta2.rom')).toBe(true);
});
(0, vitest_1.it)('verifyHashesAgainstDat finds match by CRC', () => {
const xml = fs_1.default.readFileSync(FIXTURE, 'utf8');
const dat = (0, datVerifier_1.parseDat)(xml);
const match = (0, datVerifier_1.verifyHashesAgainstDat)(dat, { crc: 'DEADBEEF' });
(0, vitest_1.expect)(match).not.toBeNull();
(0, vitest_1.expect)(match?.gameName).toBe('Game Beta');
(0, vitest_1.expect)(match?.romName).toBe('beta1.rom');
(0, vitest_1.expect)(match?.matchedOn).toBe('crc');
});
(0, vitest_1.it)('verifyHashesAgainstDat finds match by MD5, SHA1 and size', () => {
const xml = fs_1.default.readFileSync(FIXTURE, 'utf8');
const dat = (0, datVerifier_1.parseDat)(xml);
const md5match = (0, datVerifier_1.verifyHashesAgainstDat)(dat, { md5: '11111111111111111111111111111111' });
(0, vitest_1.expect)(md5match).not.toBeNull();
(0, vitest_1.expect)(md5match?.gameName).toBe('Game Alpha');
(0, vitest_1.expect)(md5match?.romName).toBe('alpha1.bin');
(0, vitest_1.expect)(md5match?.matchedOn).toBe('md5');
const sha1match = (0, datVerifier_1.verifyHashesAgainstDat)(dat, {
sha1: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
});
(0, vitest_1.expect)(sha1match).not.toBeNull();
(0, vitest_1.expect)(sha1match?.gameName).toBe('Game Alpha');
(0, vitest_1.expect)(sha1match?.romName).toBe('alpha2.bin');
(0, vitest_1.expect)(sha1match?.matchedOn).toBe('sha1');
const sizematch = (0, datVerifier_1.verifyHashesAgainstDat)(dat, { size: 4000 });
(0, vitest_1.expect)(sizematch).not.toBeNull();
(0, vitest_1.expect)(sizematch?.gameName).toBe('Game Beta');
(0, vitest_1.expect)(sizematch?.romName).toBe('beta2.rom');
(0, vitest_1.expect)(sizematch?.matchedOn).toBe('size');
});
(0, vitest_1.it)('verifyHashesAgainstDat returns null when no match', () => {
const xml = fs_1.default.readFileSync(FIXTURE, 'utf8');
const dat = (0, datVerifier_1.parseDat)(xml);
const noMatch = (0, datVerifier_1.verifyHashesAgainstDat)(dat, { md5: 'ffffffffffffffffffffffffffffffff' });
(0, vitest_1.expect)(noMatch).toBeNull();
});
});

View File

@@ -0,0 +1,69 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const path_1 = __importDefault(require("path"));
const os_1 = __importDefault(require("os"));
const fs_1 = require("fs");
const vitest_1 = require("vitest");
vitest_1.vi.mock('../../src/services/archiveReader', () => ({ listArchiveEntries: vitest_1.vi.fn() }));
const fsScanner_1 = __importDefault(require("../../src/services/fsScanner"));
const archiveReader_1 = require("../../src/services/archiveReader");
(0, vitest_1.afterEach)(() => vitest_1.vi.restoreAllMocks());
(0, vitest_1.it)('expone entradas internas de archivos como items virtuales', async () => {
const tmpDir = await fs_1.promises.mkdtemp(path_1.default.join(os_1.default.tmpdir(), 'fsScanner-test-'));
const collectionFile = path_1.default.join(tmpDir, 'collection.zip');
await fs_1.promises.writeFile(collectionFile, '');
archiveReader_1.listArchiveEntries.mockResolvedValue([
{ name: 'inner/rom1.bin', size: 1234 },
]);
const results = await (0, fsScanner_1.default)(tmpDir);
const expectedPath = `${collectionFile}::inner/rom1.bin`;
const found = results.find((r) => r.path === expectedPath);
(0, vitest_1.expect)(found).toBeDefined();
(0, vitest_1.expect)(found.isArchiveEntry).toBe(true);
(0, vitest_1.expect)(found.containerPath).toBe(collectionFile);
(0, vitest_1.expect)(found.entryPath).toBe('inner/rom1.bin');
(0, vitest_1.expect)(found.filename).toBe('rom1.bin');
(0, vitest_1.expect)(found.format).toBe('bin');
await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
});
(0, vitest_1.it)('ignora entradas con traversal o paths absolutos', async () => {
const tmpDir = await fs_1.promises.mkdtemp(path_1.default.join(os_1.default.tmpdir(), 'fsScanner-test-'));
const collectionFile = path_1.default.join(tmpDir, 'collection.zip');
await fs_1.promises.writeFile(collectionFile, '');
archiveReader_1.listArchiveEntries.mockResolvedValue([
{ name: '../evil.rom', size: 10 },
{ name: '/abs/evil.rom', size: 20 },
{ name: 'good/rom.bin', size: 30 },
]);
const results = await (0, fsScanner_1.default)(tmpDir);
const safePath = `${collectionFile}::good/rom.bin`;
(0, vitest_1.expect)(results.find((r) => r.path === safePath)).toBeDefined();
(0, vitest_1.expect)(results.find((r) => r.path === `${collectionFile}::../evil.rom`)).toBeUndefined();
(0, vitest_1.expect)(results.find((r) => r.path === `${collectionFile}::/abs/evil.rom`)).toBeUndefined();
await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
});
(0, vitest_1.it)('respeta ARCHIVE_MAX_ENTRIES', async () => {
const tmpDir = await fs_1.promises.mkdtemp(path_1.default.join(os_1.default.tmpdir(), 'fsScanner-test-'));
const collectionFile = path_1.default.join(tmpDir, 'collection.zip');
await fs_1.promises.writeFile(collectionFile, '');
// Set env var temporarily
const prev = process.env.ARCHIVE_MAX_ENTRIES;
process.env.ARCHIVE_MAX_ENTRIES = '1';
archiveReader_1.listArchiveEntries.mockResolvedValue([
{ name: 'one.bin', size: 1 },
{ name: 'two.bin', size: 2 },
{ name: 'three.bin', size: 3 },
]);
const results = await (0, fsScanner_1.default)(tmpDir);
const matches = results.filter((r) => String(r.path).startsWith(collectionFile + '::'));
(0, vitest_1.expect)(matches.length).toBe(1);
// restore
if (prev === undefined)
delete process.env.ARCHIVE_MAX_ENTRIES;
else
process.env.ARCHIVE_MAX_ENTRIES = prev;
await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
});

View File

@@ -0,0 +1,27 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const path_1 = __importDefault(require("path"));
const fsScanner_1 = require("../../src/services/fsScanner");
const fixturesDir = path_1.default.join(__dirname, '..', 'fixtures');
const emptyDir = path_1.default.join(fixturesDir, 'empty');
(0, vitest_1.describe)('services/fsScanner', () => {
(0, vitest_1.it)('exporta scanDirectory', () => {
(0, vitest_1.expect)(typeof fsScanner_1.scanDirectory).toBe('function');
});
(0, vitest_1.it)('carpeta vacía devuelve array', async () => {
const res = await (0, fsScanner_1.scanDirectory)(emptyDir);
(0, vitest_1.expect)(Array.isArray(res)).toBe(true);
(0, vitest_1.expect)(res.length).toBe(0);
});
(0, vitest_1.it)('detecta simple-rom.bin', async () => {
const res = await (0, fsScanner_1.scanDirectory)(fixturesDir);
const found = res.find((r) => r.filename === 'simple-rom.bin' || r.name === 'simple-rom.bin');
(0, vitest_1.expect)(found).toBeTruthy();
(0, vitest_1.expect)(found.size).toBeGreaterThan(0);
(0, vitest_1.expect)(found.format).toBeDefined();
});
});

View File

@@ -0,0 +1,71 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const stream_1 = require("stream");
vitest_1.vi.mock('../../src/services/fsScanner', () => ({ scanDirectory: vitest_1.vi.fn() }));
vitest_1.vi.mock('../../src/services/archiveReader', () => ({ streamArchiveEntry: vitest_1.vi.fn() }));
vitest_1.vi.mock('../../src/plugins/prisma', () => ({
default: {
game: { findFirst: vitest_1.vi.fn(), create: vitest_1.vi.fn(), update: vitest_1.vi.fn() },
},
}));
const importService_1 = __importDefault(require("../../src/services/importService"));
const fsScanner_1 = require("../../src/services/fsScanner");
const archiveReader_1 = require("../../src/services/archiveReader");
const prisma_1 = __importDefault(require("../../src/plugins/prisma"));
const crypto_1 = require("crypto");
(0, vitest_1.beforeEach)(() => {
vitest_1.vi.restoreAllMocks();
});
(0, vitest_1.describe)('services/importService (archive entries)', () => {
(0, vitest_1.it)('procesa una entrada interna usando streamArchiveEntry y crea Game con source=rom', async () => {
const files = [
{
path: '/roms/collection.zip::inner/rom1.bin',
containerPath: '/roms/collection.zip',
entryPath: 'inner/rom1.bin',
filename: 'rom1.bin',
name: 'inner/rom1.bin',
size: 123,
format: 'bin',
isArchiveEntry: true,
},
];
const data = Buffer.from('import-archive-test');
fsScanner_1.scanDirectory.mockResolvedValue(files);
archiveReader_1.streamArchiveEntry.mockResolvedValue(stream_1.Readable.from([data]));
prisma_1.default.game.findFirst.mockResolvedValue(null);
prisma_1.default.game.create.mockResolvedValue({
id: 77,
title: 'ROM1',
slug: 'rom1',
});
const md5 = (0, crypto_1.createHash)('md5').update(data).digest('hex');
const summary = await (0, importService_1.default)({ dir: '/roms', persist: true });
(0, vitest_1.expect)(archiveReader_1.streamArchiveEntry.mock.calls.length).toBe(1);
(0, vitest_1.expect)(archiveReader_1.streamArchiveEntry.mock.calls[0][0]).toBe('/roms/collection.zip');
(0, vitest_1.expect)(archiveReader_1.streamArchiveEntry.mock.calls[0][1]).toBe('inner/rom1.bin');
(0, vitest_1.expect)(prisma_1.default.game.findFirst.mock.calls[0][0]).toEqual({
where: { source: 'rom', romChecksum: md5 },
});
(0, vitest_1.expect)(prisma_1.default.game.create.mock.calls[0][0]).toEqual({
data: {
title: 'ROM1',
slug: 'rom1-1234567890123',
source: 'rom',
romPath: '/roms/collection.zip::inner/rom1.bin',
romFilename: 'rom1.bin',
romSize: 123,
romChecksum: md5,
romFormat: 'bin',
romHashes: vitest_1.expect.any(String),
addedAt: vitest_1.expect.any(Date),
lastSeenAt: vitest_1.expect.any(Date),
},
});
(0, vitest_1.expect)(summary).toEqual({ processed: 1, createdCount: 1, upserted: 0 });
});
});

View File

@@ -0,0 +1,127 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
vitest_1.vi.mock('../../src/services/fsScanner', () => ({
scanDirectory: vitest_1.vi.fn(),
}));
vitest_1.vi.mock('../../src/services/checksumService', () => ({
computeHashes: vitest_1.vi.fn(),
}));
vitest_1.vi.mock('../../src/plugins/prisma', () => ({
default: {
game: { findFirst: vitest_1.vi.fn(), create: vitest_1.vi.fn(), update: vitest_1.vi.fn() },
},
}));
const importService_1 = require("../../src/services/importService");
const fsScanner_1 = require("../../src/services/fsScanner");
const checksumService_1 = require("../../src/services/checksumService");
const prisma_1 = __importDefault(require("../../src/plugins/prisma"));
(0, vitest_1.describe)('services/importService', () => {
(0, vitest_1.beforeEach)(() => {
vitest_1.vi.clearAllMocks();
});
(0, vitest_1.it)('exporta createSlug e importDirectory', () => {
(0, vitest_1.expect)(typeof importService_1.createSlug).toBe('function');
(0, vitest_1.expect)(typeof importService_1.importDirectory).toBe('function');
});
(0, vitest_1.it)('cuando hay un archivo y persist:true crea Game con source=rom y devuelve resumen', async () => {
const files = [
{
path: '/roms/Sonic.bin',
filename: 'Sonic.bin',
name: 'Sonic.bin',
size: 123,
format: 'bin',
isArchive: false,
},
];
const hashes = { size: 123, md5: 'md5-abc', sha1: 'sha1-abc', crc32: 'abcd' };
fsScanner_1.scanDirectory.mockResolvedValue(files);
checksumService_1.computeHashes.mockResolvedValue(hashes);
prisma_1.default.game.findFirst.mockResolvedValue(null);
prisma_1.default.game.create.mockResolvedValue({
id: 77,
title: 'Sonic',
slug: 'sonic',
});
const summary = await (0, importService_1.importDirectory)({ dir: '/roms', persist: true });
(0, vitest_1.expect)(fsScanner_1.scanDirectory.mock.calls[0][0]).toBe('/roms');
(0, vitest_1.expect)(checksumService_1.computeHashes.mock.calls[0][0]).toBe('/roms/Sonic.bin');
(0, vitest_1.expect)(prisma_1.default.game.findFirst.mock.calls[0][0]).toEqual({
where: { source: 'rom', romChecksum: 'md5-abc' },
});
(0, vitest_1.expect)(prisma_1.default.game.create.mock.calls[0][0]).toEqual({
data: {
title: 'Sonic',
slug: 'sonic-1234567890123',
source: 'rom',
romPath: '/roms/Sonic.bin',
romFilename: 'Sonic.bin',
romSize: 123,
romChecksum: 'md5-abc',
romFormat: 'bin',
romHashes: JSON.stringify(hashes),
addedAt: vitest_1.expect.any(Date),
lastSeenAt: vitest_1.expect.any(Date),
},
});
(0, vitest_1.expect)(summary).toEqual({ processed: 1, createdCount: 1, upserted: 0 });
});
(0, vitest_1.it)('cuando el juego ya existe (mismo checksum), actualiza lastSeenAt', async () => {
const files = [
{
path: '/roms/Sonic.bin',
filename: 'Sonic.bin',
name: 'Sonic.bin',
size: 123,
format: 'bin',
isArchive: false,
},
];
const hashes = { size: 123, md5: 'md5-abc', sha1: 'sha1-abc', crc32: 'abcd' };
fsScanner_1.scanDirectory.mockResolvedValue(files);
checksumService_1.computeHashes.mockResolvedValue(hashes);
prisma_1.default.game.findFirst.mockResolvedValue({
id: 77,
title: 'Sonic',
slug: 'sonic',
});
prisma_1.default.game.update.mockResolvedValue({
id: 77,
title: 'Sonic',
slug: 'sonic',
});
const summary = await (0, importService_1.importDirectory)({ dir: '/roms', persist: true });
(0, vitest_1.expect)(prisma_1.default.game.update.mock.calls[0][0]).toEqual({
where: { id: 77 },
data: {
lastSeenAt: vitest_1.expect.any(Date),
romHashes: JSON.stringify(hashes),
},
});
(0, vitest_1.expect)(summary).toEqual({ processed: 1, createdCount: 0, upserted: 1 });
});
(0, vitest_1.it)('cuando persist=false no guarda nada en la base de datos', async () => {
const files = [
{
path: '/roms/Sonic.bin',
filename: 'Sonic.bin',
name: 'Sonic.bin',
size: 123,
format: 'bin',
isArchive: false,
},
];
const hashes = { size: 123, md5: 'md5-abc', sha1: 'sha1-abc', crc32: 'abcd' };
fsScanner_1.scanDirectory.mockResolvedValue(files);
checksumService_1.computeHashes.mockResolvedValue(hashes);
const summary = await (0, importService_1.importDirectory)({ dir: '/roms', persist: false });
(0, vitest_1.expect)(prisma_1.default.game.findFirst).not.toHaveBeenCalled();
(0, vitest_1.expect)(prisma_1.default.game.create).not.toHaveBeenCalled();
(0, vitest_1.expect)(prisma_1.default.game.update).not.toHaveBeenCalled();
(0, vitest_1.expect)(summary).toEqual({ processed: 1, createdCount: 0, upserted: 0 });
});
});

View File

@@ -0,0 +1,103 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
vitest_1.vi.mock('../../src/services/igdbClient', () => ({
searchGames: vitest_1.vi.fn(),
getGameById: vitest_1.vi.fn(),
}));
vitest_1.vi.mock('../../src/services/rawgClient', () => ({
searchGames: vitest_1.vi.fn(),
getGameById: vitest_1.vi.fn(),
}));
vitest_1.vi.mock('../../src/services/thegamesdbClient', () => ({
searchGames: vitest_1.vi.fn(),
getGameById: vitest_1.vi.fn(),
}));
const igdb = __importStar(require("../../src/services/igdbClient"));
const rawg = __importStar(require("../../src/services/rawgClient"));
const tgdb = __importStar(require("../../src/services/thegamesdbClient"));
const metadataService_1 = require("../../src/services/metadataService");
(0, vitest_1.describe)('services/metadataService', () => {
(0, vitest_1.beforeEach)(() => {
vitest_1.vi.clearAllMocks();
});
(0, vitest_1.it)('prioriza IGDB cuando hay resultados', async () => {
igdb.searchGames.mockResolvedValue([
{
id: 11,
name: 'Sonic',
slug: 'sonic',
releaseDate: '1991-06-23',
genres: ['Platform'],
coverUrl: 'http://img',
source: 'igdb',
},
]);
rawg.searchGames.mockResolvedValue([]);
tgdb.searchGames.mockResolvedValue([]);
const res = await (0, metadataService_1.enrichGame)({ title: 'Sonic' });
(0, vitest_1.expect)(res).not.toBeNull();
(0, vitest_1.expect)(res?.source).toBe('igdb');
(0, vitest_1.expect)(res?.externalIds.igdb).toBe(11);
(0, vitest_1.expect)(res?.title).toBe('Sonic');
});
(0, vitest_1.it)('cae a RAWG cuando IGDB no responde resultados', async () => {
igdb.searchGames.mockResolvedValue([]);
rawg.searchGames.mockResolvedValue([
{
id: 22,
name: 'Sonic (rawg)',
slug: 'sonic-rawg',
releaseDate: '1991-06-23',
genres: ['Platform'],
coverUrl: 'http://img',
source: 'rawg',
},
]);
tgdb.searchGames.mockResolvedValue([]);
const res = await (0, metadataService_1.enrichGame)({ title: 'Sonic' });
(0, vitest_1.expect)(res).not.toBeNull();
(0, vitest_1.expect)(res?.source).toBe('rawg');
(0, vitest_1.expect)(res?.externalIds.rawg).toBe(22);
});
(0, vitest_1.it)('retorna null si no hay resultados en ninguna API', async () => {
igdb.searchGames.mockResolvedValue([]);
rawg.searchGames.mockResolvedValue([]);
tgdb.searchGames.mockResolvedValue([]);
const res = await (0, metadataService_1.enrichGame)({ title: 'Juego inexistente' });
(0, vitest_1.expect)(res).toBeNull();
});
});

24
backend/dist/tests/setup.js vendored Normal file
View File

@@ -0,0 +1,24 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const dotenv_1 = __importDefault(require("dotenv"));
const child_process_1 = require("child_process");
// Cargar variables de entorno desde .env
dotenv_1.default.config();
// Ejecutar migraciones de Prisma antes de los tests
try {
(0, child_process_1.execSync)('npx prisma migrate deploy', {
cwd: process.cwd(),
stdio: 'inherit',
});
}
catch (error) {
console.error('Failed to run Prisma migrations:', error);
}
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-12
*/

View File

@@ -0,0 +1,62 @@
/*
Warnings:
- You are about to drop the `RomFile` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the column `extra` on the `Game` table. All the data in the column will be lost.
- Added the required column `source` to the `Game` table without a default value. This is not possible if the table is not empty.
*/
-- DropIndex
DROP INDEX "RomFile_checksum_idx";
-- DropIndex
DROP INDEX "RomFile_checksum_key";
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "RomFile";
PRAGMA foreign_keys=on;
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Game" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"releaseDate" DATETIME,
"genre" TEXT,
"platform" TEXT,
"year" INTEGER,
"cover" TEXT,
"source" TEXT NOT NULL,
"sourceId" TEXT,
"romPath" TEXT,
"romFilename" TEXT,
"romSize" INTEGER,
"romChecksum" TEXT,
"romFormat" TEXT,
"romHashes" TEXT,
"igdbId" INTEGER,
"rawgId" INTEGER,
"thegamesdbId" INTEGER,
"metadata" TEXT,
"addedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Game" ("createdAt", "description", "id", "igdbId", "rawgId", "releaseDate", "slug", "thegamesdbId", "title", "updatedAt") SELECT "createdAt", "description", "id", "igdbId", "rawgId", "releaseDate", "slug", "thegamesdbId", "title", "updatedAt" FROM "Game";
DROP TABLE "Game";
ALTER TABLE "new_Game" RENAME TO "Game";
CREATE UNIQUE INDEX "Game_slug_key" ON "Game"("slug");
CREATE UNIQUE INDEX "Game_igdbId_key" ON "Game"("igdbId");
CREATE UNIQUE INDEX "Game_rawgId_key" ON "Game"("rawgId");
CREATE UNIQUE INDEX "Game_thegamesdbId_key" ON "Game"("thegamesdbId");
CREATE INDEX "Game_source_idx" ON "Game"("source");
CREATE INDEX "Game_sourceId_idx" ON "Game"("sourceId");
CREATE INDEX "Game_title_idx" ON "Game"("title");
CREATE INDEX "Game_romChecksum_idx" ON "Game"("romChecksum");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,43 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Game" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"releaseDate" DATETIME,
"genre" TEXT,
"platform" TEXT,
"year" INTEGER,
"cover" TEXT,
"source" TEXT NOT NULL DEFAULT 'manual',
"sourceId" TEXT,
"romPath" TEXT,
"romFilename" TEXT,
"romSize" INTEGER,
"romChecksum" TEXT,
"romFormat" TEXT,
"romHashes" TEXT,
"igdbId" INTEGER,
"rawgId" INTEGER,
"thegamesdbId" INTEGER,
"metadata" TEXT,
"addedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Game" ("addedAt", "cover", "createdAt", "description", "genre", "id", "igdbId", "lastSeenAt", "metadata", "platform", "rawgId", "releaseDate", "romChecksum", "romFilename", "romFormat", "romHashes", "romPath", "romSize", "slug", "source", "sourceId", "thegamesdbId", "title", "updatedAt", "year") SELECT "addedAt", "cover", "createdAt", "description", "genre", "id", "igdbId", "lastSeenAt", "metadata", "platform", "rawgId", "releaseDate", "romChecksum", "romFilename", "romFormat", "romHashes", "romPath", "romSize", "slug", "source", "sourceId", "thegamesdbId", "title", "updatedAt", "year" FROM "Game";
DROP TABLE "Game";
ALTER TABLE "new_Game" RENAME TO "Game";
CREATE UNIQUE INDEX "Game_slug_key" ON "Game"("slug");
CREATE UNIQUE INDEX "Game_igdbId_key" ON "Game"("igdbId");
CREATE UNIQUE INDEX "Game_rawgId_key" ON "Game"("rawgId");
CREATE UNIQUE INDEX "Game_thegamesdbId_key" ON "Game"("thegamesdbId");
CREATE INDEX "Game_source_idx" ON "Game"("source");
CREATE INDEX "Game_sourceId_idx" ON "Game"("sourceId");
CREATE INDEX "Game_title_idx" ON "Game"("title");
CREATE INDEX "Game_romChecksum_idx" ON "Game"("romChecksum");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -15,19 +15,49 @@ model Game {
slug String @unique
description String?
releaseDate DateTime?
genre String?
platform String?
year Int?
cover String?
// Fuente del juego
source String @default("manual") // "rom", "manual", "igdb", "rawg", "thegamesdb", etc.
sourceId String? // ID en la fuente externa (para igdb, rawg, etc.)
// Datos específicos de ROM (si source = "rom")
romPath String?
romFilename String?
romSize Int?
romChecksum String?
romFormat String?
romHashes String? // JSON serialized (ej.: {"crc32": "...", "md5": "...", "sha1": "..."})
// IDs de integraciones externas (mantener compatibilidad con datos existentes)
igdbId Int? @unique
rawgId Int? @unique
thegamesdbId Int? @unique
extra String? // JSON serialized (usar parse/stringify al guardar/leer) para compatibilidad con SQLite
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
romFiles RomFile[]
// Metadatos adicionales de integraciones
metadata String? // JSON serialized para datos adicionales de la fuente
// Relaciones existentes
artworks Artwork[]
purchases Purchase[]
gamePlatforms GamePlatform[]
priceHistories PriceHistory[]
tags Tag[]
// Timestamps de ROM (para compatibilidad)
addedAt DateTime @default(now())
lastSeenAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([source])
@@index([sourceId])
@@index([title])
@@index([romChecksum])
}
model Platform {
@@ -47,22 +77,6 @@ model GamePlatform {
@@unique([gameId, platformId])
}
model RomFile {
id String @id @default(cuid())
path String
filename String
checksum String @unique
size Int
format String
hashes String? // JSON serialized (ej.: {"crc32": "...", "md5": "..."})
game Game? @relation(fields: [gameId], references: [id])
gameId String?
addedAt DateTime @default(now())
lastSeenAt DateTime?
status String @default("active")
@@index([checksum])
}
model Artwork {
id String @id @default(cuid())
game Game @relation(fields: [gameId], references: [id])
@@ -104,4 +118,5 @@ model PriceHistory {
// Metadatos:
// Autor: GitHub Copilot
// Última actualización: 2026-02-07
// Última actualización: 2026-03-18
// Unificación de juegos y ROMs en una sola entidad Game

View File

@@ -5,7 +5,6 @@ import rateLimit from '@fastify/rate-limit';
import healthRoutes from './routes/health';
import importRoutes from './routes/import';
import gamesRoutes from './routes/games';
import romsRoutes from './routes/roms';
import metadataRoutes from './routes/metadata';
export function buildApp(): FastifyInstance {
@@ -19,7 +18,6 @@ export function buildApp(): FastifyInstance {
void app.register(healthRoutes, { prefix: '/api' });
void app.register(importRoutes, { prefix: '/api' });
void app.register(gamesRoutes, { prefix: '/api' });
void app.register(romsRoutes, { prefix: '/api' });
void app.register(metadataRoutes, { prefix: '/api' });
return app;

View File

@@ -22,11 +22,67 @@ export class GamesController {
});
}
/**
* Obtener un juego por ID
*/
static async getGameById(id: string) {
const game = await prisma.game.findUnique({
where: { id },
include: {
gamePlatforms: {
include: {
platform: true,
},
},
purchases: true,
artworks: true,
tags: true,
},
});
if (!game) {
throw new Error('Juego no encontrado');
}
return game;
}
/**
* Listar juegos por fuente (rom, manual, igdb, rawg, etc.)
*/
static async listGamesBySource(source: string) {
return await prisma.game.findMany({
where: { source },
include: {
gamePlatforms: {
include: {
platform: true,
},
},
purchases: true,
},
orderBy: {
title: 'asc',
},
});
}
/**
* Crear un juego nuevo
*/
static async createGame(input: CreateGameInput) {
const { title, platformId, description, priceCents, currency, store, date, condition } = input;
const {
title,
platformId,
description,
priceCents,
currency,
store,
date,
condition,
source,
sourceId,
} = input;
// Generar slug basado en el título
const slug = title
@@ -38,6 +94,8 @@ export class GamesController {
title,
slug: `${slug}-${Date.now()}`, // Hacer slug único agregando timestamp
description: description || null,
source: source || 'manual',
sourceId: sourceId || null,
};
// Si se proporciona una plataforma, crearla en gamePlatforms
@@ -78,7 +136,8 @@ export class GamesController {
* Actualizar un juego existente
*/
static async updateGame(id: string, input: UpdateGameInput) {
const { title, platformId, description, priceCents, currency, store, date } = input;
const { title, platformId, description, priceCents, currency, store, date, source, sourceId } =
input;
const updateData: Prisma.GameUpdateInput = {};
@@ -96,6 +155,14 @@ export class GamesController {
updateData.description = description;
}
if (source !== undefined) {
updateData.source = source;
}
if (sourceId !== undefined) {
updateData.sourceId = sourceId;
}
const game = await prisma.game.update({
where: { id },
data: updateData,
@@ -176,5 +243,6 @@ export class GamesController {
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
* Última actualización: 2026-03-18
* Actualizado para soportar fuente (source) en juegos
*/

View File

@@ -1,96 +0,0 @@
import { prisma } from '../plugins/prisma';
export class RomsController {
/**
* Listar todos los ROMs con sus juegos asociados
*/
static async listRoms() {
return await prisma.romFile.findMany({
include: {
game: true,
},
orderBy: {
filename: 'asc',
},
});
}
/**
* Obtener un ROM por ID con su juego asociado
*/
static async getRomById(id: string) {
const rom = await prisma.romFile.findUnique({
where: { id },
include: {
game: true,
},
});
if (!rom) {
throw new Error('ROM no encontrado');
}
return rom;
}
/**
* Vincular un juego a un ROM existente
*/
static async linkGameToRom(romId: string, gameId: string) {
// Validar que el ROM existe
const rom = await prisma.romFile.findUnique({
where: { id: romId },
});
if (!rom) {
throw new Error('ROM no encontrado');
}
// Validar que el juego existe
const game = await prisma.game.findUnique({
where: { id: gameId },
});
if (!game) {
throw new Error('Juego no encontrado');
}
// Actualizar el ROM con el nuevo gameId
return await prisma.romFile.update({
where: { id: romId },
data: {
gameId,
},
include: {
game: true,
},
});
}
/**
* Eliminar un ROM por ID
*/
static async deleteRom(id: string) {
// Validar que el ROM existe
const rom = await prisma.romFile.findUnique({
where: { id },
});
if (!rom) {
throw new Error('ROM no encontrado');
}
// Eliminar el ROM
await prisma.romFile.delete({
where: { id },
});
return { message: 'ROM eliminado correctamente' };
}
}
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -13,6 +13,24 @@ async function gamesRoutes(app: FastifyInstance) {
return reply.code(200).send(games);
});
/**
* GET /api/games/:id
* Obtener un juego por ID
*/
app.get<{ Params: { id: string }; Reply: any }>('/games/:id', async (request, reply) => {
try {
const game = await GamesController.getGameById(request.params.id);
return reply.code(200).send(game);
} catch (error) {
if (error instanceof Error && error.message.includes('no encontrado')) {
return reply.code(404).send({
error: 'Juego no encontrado',
});
}
throw error;
}
});
/**
* POST /api/games
* Crear un nuevo juego
@@ -80,6 +98,18 @@ async function gamesRoutes(app: FastifyInstance) {
throw error;
}
});
/**
* GET /api/games/source/:source
* Listar juegos por fuente (rom, manual, igdb, rawg, etc.)
*/
app.get<{ Params: { source: string }; Reply: any[] }>(
'/games/source/:source',
async (request, reply) => {
const games = await GamesController.listGamesBySource(request.params.source);
return reply.code(200).send(games);
}
);
}
export default gamesRoutes;
@@ -87,5 +117,6 @@ export default gamesRoutes;
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
* Última actualización: 2026-03-18
* Actualizado para soportar fuente (source) en juegos
*/

View File

@@ -1,95 +0,0 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { RomsController } from '../controllers/romsController';
import { linkGameSchema } from '../validators/romValidator';
import { ZodError } from 'zod';
async function romsRoutes(app: FastifyInstance) {
/**
* GET /api/roms
* Listar todos los ROMs
*/
app.get<{ Reply: any[] }>('/roms', async (request, reply) => {
const roms = await RomsController.listRoms();
return reply.code(200).send(roms);
});
/**
* GET /api/roms/:id
* Obtener un ROM por ID
*/
app.get<{ Params: { id: string }; Reply: any }>('/roms/:id', async (request, reply) => {
try {
const rom = await RomsController.getRomById(request.params.id);
return reply.code(200).send(rom);
} catch (error) {
if (error instanceof Error && error.message.includes('no encontrado')) {
return reply.code(404).send({
error: 'ROM no encontrado',
});
}
throw error;
}
});
/**
* PUT /api/roms/:id/game
* Vincular un juego a un ROM
*/
app.put<{ Params: { id: string }; Body: any; Reply: any }>(
'/roms/:id/game',
async (request, reply) => {
try {
// Validar entrada con Zod
const validated = linkGameSchema.parse(request.body);
const rom = await RomsController.linkGameToRom(request.params.id, validated.gameId);
return reply.code(200).send(rom);
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({
error: 'Validación fallida',
details: error.errors,
});
}
if (error instanceof Error) {
if (error.message.includes('ROM no encontrado')) {
return reply.code(404).send({
error: 'ROM no encontrado',
});
}
if (error.message.includes('Juego no encontrado')) {
return reply.code(400).send({
error: 'Game ID inválido o no encontrado',
});
}
}
throw error;
}
}
);
/**
* DELETE /api/roms/:id
* Eliminar un ROM
*/
app.delete<{ Params: { id: string }; Reply: any }>('/roms/:id', async (request, reply) => {
try {
await RomsController.deleteRom(request.params.id);
return reply.code(204).send();
} catch (error) {
if (error instanceof Error && error.message.includes('no encontrado')) {
return reply.code(404).send({
error: 'ROM no encontrado',
});
}
throw error;
}
});
}
export default romsRoutes;
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -1,11 +1,11 @@
/**
* Servicio: importService
*
* Orquesta el proceso de importación de ROMs desde un directorio:
* Orquesta el proceso de importación de juegos desde un directorio:
* 1. Lista archivos usando `scanDirectory`.
* 2. Calcula hashes y tamaño con `computeHashes` (streaming).
* 3. Normaliza el nombre a un `slug` y, si `persist` es true, crea/obtiene
* el `Game` correspondiente y hace `upsert` del `RomFile` en Prisma.
* el `Game` correspondiente con source="rom".
*
* `importDirectory` devuelve un resumen con contadores `{ processed, createdCount, upserted }`.
*/
@@ -89,32 +89,45 @@ export async function importDirectory(
const baseName = path.parse(file.filename).name;
const slug = createSlug(baseName);
let game = null;
if (persist) {
game = await prisma.game.findUnique({ where: { slug } });
if (!game) {
game = await prisma.game.create({ data: { title: baseName, slug } });
createdCount++;
}
await prisma.romFile.upsert({
where: { checksum },
update: { lastSeenAt: new Date(), size, hashes: JSON.stringify(hashes) },
create: {
path: file.path,
filename: file.filename,
checksum,
size,
format: file.format,
hashes: JSON.stringify(hashes),
gameId: game?.id,
// Buscar si ya existe un juego con este checksum (source=rom)
let game = await prisma.game.findFirst({
where: {
source: 'rom',
romChecksum: checksum,
},
});
if (!game) {
// Crear nuevo juego con source="rom"
game = await prisma.game.create({
data: {
title: baseName,
slug: `${slug}-${Date.now()}`,
source: 'rom',
romPath: file.path,
romFilename: file.filename,
romSize: size,
romChecksum: checksum,
romFormat: file.format,
romHashes: JSON.stringify(hashes),
addedAt: new Date(),
lastSeenAt: new Date(),
},
});
createdCount++;
} else {
// Actualizar lastSeenAt si ya existe
game = await prisma.game.update({
where: { id: game.id },
data: {
lastSeenAt: new Date(),
romHashes: JSON.stringify(hashes),
},
});
upserted++;
}
}
} catch (err) {
logger.warn?.(
{ err, file },

View File

@@ -3,6 +3,9 @@ import { z } from 'zod';
// Enum para condiciones (Loose, CIB, New)
export const GameCondition = z.enum(['Loose', 'CIB', 'New']).optional();
// Enum para fuentes de juegos
export const GameSource = z.enum(['manual', 'rom', 'igdb', 'rawg', 'thegamesdb']).optional();
// Esquema de validación para crear un juego
export const createGameSchema = z.object({
title: z.string().min(1, 'El título es requerido').trim(),
@@ -13,6 +16,8 @@ export const createGameSchema = z.object({
store: z.string().optional(),
date: z.string().optional(), // Acepta formato ISO (YYYY-MM-DD o ISO completo)
condition: GameCondition,
source: z.string().optional().default('manual'), // Fuente del juego
sourceId: z.string().optional(), // ID en la fuente externa
});
// Esquema de validación para actualizar un juego (todos los campos son opcionales)
@@ -26,6 +31,8 @@ export const updateGameSchema = z
store: z.string().optional(),
date: z.string().optional(), // Acepta formato ISO (YYYY-MM-DD o ISO completo)
condition: GameCondition,
source: z.string().optional(), // Fuente del juego
sourceId: z.string().optional(), // ID en la fuente externa
})
.strict();

View File

@@ -11,7 +11,6 @@ describe('Games API', () => {
await app.ready();
// Limpiar base de datos antes de cada test
// Orden importante: relaciones de FK primero
await prisma.romFile.deleteMany();
await prisma.purchase.deleteMany();
await prisma.gamePlatform.deleteMany();
await prisma.artwork.deleteMany();
@@ -46,6 +45,7 @@ describe('Games API', () => {
title: 'The Legend of Zelda',
slug: 'legend-of-zelda',
description: 'Un videojuego clásico',
source: 'manual',
gamePlatforms: {
create: {
platformId: platform.id,
@@ -163,6 +163,7 @@ describe('Games API', () => {
data: {
title: 'Original Title',
slug: 'original-title',
source: 'manual',
},
});
@@ -199,6 +200,7 @@ describe('Games API', () => {
title: 'Original Title',
slug: 'original',
description: 'Original description',
source: 'manual',
},
});
@@ -223,6 +225,7 @@ describe('Games API', () => {
data: {
title: 'Game to Delete',
slug: 'game-to-delete',
source: 'manual',
},
});

View File

@@ -1,295 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { buildApp } from '../../src/app';
import { FastifyInstance } from 'fastify';
import { prisma } from '../../src/plugins/prisma';
describe('ROMs API', () => {
let app: FastifyInstance;
beforeEach(async () => {
app = buildApp();
await app.ready();
// Limpiar base de datos antes de cada test (eliminar ROMs primero por foreign key)
await prisma.romFile.deleteMany();
await prisma.gamePlatform.deleteMany();
await prisma.purchase.deleteMany();
await prisma.artwork.deleteMany();
await prisma.priceHistory.deleteMany();
await prisma.game.deleteMany();
});
afterEach(async () => {
await app.close();
});
describe('GET /api/roms', () => {
it('debería devolver una lista vacía cuando no hay ROMs', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/roms',
});
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual([]);
});
it('debería devolver una lista de ROMs con sus propiedades', async () => {
// Crear un ROM de prueba
const rom = await prisma.romFile.create({
data: {
path: '/roms/games/',
filename: 'game.zip',
checksum: 'abc123def456',
size: 1024,
format: 'zip',
},
});
const res = await app.inject({
method: 'GET',
url: '/api/roms',
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBe(1);
expect(body[0].id).toBe(rom.id);
expect(body[0].filename).toBe('game.zip');
});
it('debería incluir información del juego asociado', async () => {
const game = await prisma.game.create({
data: {
title: 'Test Game',
slug: 'test-game',
},
});
const rom = await prisma.romFile.create({
data: {
path: '/roms/',
filename: 'test-with-game.zip',
checksum: 'checksum-game-123',
size: 2048,
format: 'zip',
gameId: game.id,
},
});
const res = await app.inject({
method: 'GET',
url: '/api/roms',
});
expect(res.statusCode).toBe(200);
const body = res.json();
// Buscar el ROM que creamos por checksum
const createdRom = body.find((r: any) => r.checksum === 'checksum-game-123');
expect(createdRom).toBeDefined();
expect(createdRom.game).toBeDefined();
expect(createdRom.game.title).toBe('Test Game');
});
});
describe('GET /api/roms/:id', () => {
it('debería retornar un ROM existente', async () => {
const rom = await prisma.romFile.create({
data: {
path: '/roms/',
filename: 'game1.zip',
checksum: 'checksum1',
size: 1024,
format: 'zip',
},
});
const res = await app.inject({
method: 'GET',
url: `/api/roms/${rom.id}`,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.id).toBe(rom.id);
expect(body.filename).toBe('game1.zip');
});
it('debería retornar 404 si el ROM no existe', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/roms/non-existing-id',
});
expect(res.statusCode).toBe(404);
expect(res.json()).toHaveProperty('error');
});
it('debería incluir el juego asociado al ROM', async () => {
const game = await prisma.game.create({
data: {
title: 'Zelda',
slug: 'zelda',
},
});
const rom = await prisma.romFile.create({
data: {
path: '/roms/',
filename: 'zelda.zip',
checksum: 'checksum2',
size: 2048,
format: 'zip',
gameId: game.id,
},
});
const res = await app.inject({
method: 'GET',
url: `/api/roms/${rom.id}`,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.game).toBeDefined();
expect(body.game.title).toBe('Zelda');
});
});
describe('PUT /api/roms/:id/game', () => {
it('debería vincular un juego a un ROM existente', async () => {
const game = await prisma.game.create({
data: {
title: 'Mario',
slug: 'mario',
},
});
const rom = await prisma.romFile.create({
data: {
path: '/roms/',
filename: 'mario.zip',
checksum: 'checksum3',
size: 512,
format: 'zip',
},
});
const res = await app.inject({
method: 'PUT',
url: `/api/roms/${rom.id}/game`,
payload: {
gameId: game.id,
},
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.gameId).toBe(game.id);
expect(body.game.title).toBe('Mario');
});
it('debería devolver 400 si el gameId es inválido', async () => {
const rom = await prisma.romFile.create({
data: {
path: '/roms/',
filename: 'game.zip',
checksum: 'checksum4',
size: 1024,
format: 'zip',
},
});
const res = await app.inject({
method: 'PUT',
url: `/api/roms/${rom.id}/game`,
payload: {
gameId: 'invalid-game-id',
},
});
expect(res.statusCode).toBe(400);
});
it('debería devolver 404 si el ROM no existe', async () => {
const game = await prisma.game.create({
data: {
title: 'Test',
slug: 'test',
},
});
const res = await app.inject({
method: 'PUT',
url: '/api/roms/non-existing-id/game',
payload: {
gameId: game.id,
},
});
expect(res.statusCode).toBe(404);
});
it('debería devolver 400 si falta gameId', async () => {
const rom = await prisma.romFile.create({
data: {
path: '/roms/',
filename: 'game.zip',
checksum: 'checksum5',
size: 1024,
format: 'zip',
},
});
const res = await app.inject({
method: 'PUT',
url: `/api/roms/${rom.id}/game`,
payload: {},
});
expect(res.statusCode).toBe(400);
});
});
describe('DELETE /api/roms/:id', () => {
it('debería eliminar un ROM existente', async () => {
const rom = await prisma.romFile.create({
data: {
path: '/roms/',
filename: 'delete-me.zip',
checksum: 'checksum6',
size: 1024,
format: 'zip',
},
});
const res = await app.inject({
method: 'DELETE',
url: `/api/roms/${rom.id}`,
});
expect(res.statusCode).toBe(204);
// Verificar que el ROM fue eliminado
const deletedRom = await prisma.romFile.findUnique({
where: { id: rom.id },
});
expect(deletedRom).toBeNull();
});
it('debería devolver 404 si el ROM no existe', async () => {
const res = await app.inject({
method: 'DELETE',
url: '/api/roms/non-existing-id',
});
expect(res.statusCode).toBe(404);
});
});
});
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -6,8 +6,7 @@ vi.mock('../../src/services/fsScanner', () => ({ scanDirectory: vi.fn() }));
vi.mock('../../src/services/archiveReader', () => ({ streamArchiveEntry: vi.fn() }));
vi.mock('../../src/plugins/prisma', () => ({
default: {
game: { findUnique: vi.fn(), create: vi.fn() },
romFile: { upsert: vi.fn() },
game: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn() },
},
}));
@@ -22,7 +21,7 @@ beforeEach(() => {
});
describe('services/importService (archive entries)', () => {
it('procesa una entrada interna usando streamArchiveEntry y hace upsert', async () => {
it('procesa una entrada interna usando streamArchiveEntry y crea Game con source=rom', async () => {
const files = [
{
path: '/roms/collection.zip::inner/rom1.bin',
@@ -41,13 +40,12 @@ describe('services/importService (archive entries)', () => {
(scanDirectory as unknown as Mock).mockResolvedValue(files);
(streamArchiveEntry as unknown as Mock).mockResolvedValue(Readable.from([data]));
(prisma.game.findUnique as unknown as Mock).mockResolvedValue(null);
(prisma.game.findFirst as unknown as Mock).mockResolvedValue(null);
(prisma.game.create as unknown as Mock).mockResolvedValue({
id: 77,
title: 'ROM1',
slug: 'rom1',
});
(prisma.romFile.upsert as unknown as Mock).mockResolvedValue({ id: 1 });
const md5 = createHash('md5').update(data).digest('hex');
@@ -57,12 +55,25 @@ describe('services/importService (archive entries)', () => {
expect((streamArchiveEntry as unknown as Mock).mock.calls[0][0]).toBe('/roms/collection.zip');
expect((streamArchiveEntry as unknown as Mock).mock.calls[0][1]).toBe('inner/rom1.bin');
expect((prisma.romFile.upsert as unknown as Mock).mock.calls.length).toBe(1);
const upsertArgs = (prisma.romFile.upsert as unknown as Mock).mock.calls[0][0];
expect(upsertArgs.where).toEqual({ checksum: md5 });
expect(upsertArgs.create.filename).toBe('rom1.bin');
expect(upsertArgs.create.path).toBe('/roms/collection.zip::inner/rom1.bin');
expect((prisma.game.findFirst as unknown as Mock).mock.calls[0][0]).toEqual({
where: { source: 'rom', romChecksum: md5 },
});
expect((prisma.game.create as unknown as Mock).mock.calls[0][0]).toEqual({
data: {
title: 'ROM1',
slug: 'rom1-1234567890123',
source: 'rom',
romPath: '/roms/collection.zip::inner/rom1.bin',
romFilename: 'rom1.bin',
romSize: 123,
romChecksum: md5,
romFormat: 'bin',
romHashes: expect.any(String),
addedAt: expect.any(Date),
lastSeenAt: expect.any(Date),
},
});
expect(summary).toEqual({ processed: 1, createdCount: 1, upserted: 1 });
expect(summary).toEqual({ processed: 1, createdCount: 1, upserted: 0 });
});
});

View File

@@ -11,8 +11,7 @@ vi.mock('../../src/services/checksumService', () => ({
vi.mock('../../src/plugins/prisma', () => ({
default: {
game: { findUnique: vi.fn(), create: vi.fn() },
romFile: { upsert: vi.fn() },
game: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn() },
},
}));
@@ -31,7 +30,7 @@ describe('services/importService', () => {
expect(typeof importDirectory).toBe('function');
});
it('cuando hay un archivo y persist:true crea Game y hace romFile.upsert, y devuelve resumen', async () => {
it('cuando hay un archivo y persist:true crea Game con source=rom y devuelve resumen', async () => {
const files = [
{
path: '/roms/Sonic.bin',
@@ -48,32 +47,104 @@ describe('services/importService', () => {
(scanDirectory as unknown as Mock).mockResolvedValue(files);
(computeHashes as unknown as Mock).mockResolvedValue(hashes);
(prisma.game.findUnique as unknown as Mock).mockResolvedValue(null);
(prisma.game.findFirst as unknown as Mock).mockResolvedValue(null);
(prisma.game.create as unknown as Mock).mockResolvedValue({
id: 77,
title: 'Sonic',
slug: 'sonic',
});
(prisma.romFile.upsert as unknown as Mock).mockResolvedValue({ id: 1 });
const summary = await importDirectory({ dir: '/roms', persist: true });
expect((scanDirectory as unknown as Mock).mock.calls[0][0]).toBe('/roms');
expect((computeHashes as unknown as Mock).mock.calls[0][0]).toBe('/roms/Sonic.bin');
expect((prisma.game.findUnique as unknown as Mock).mock.calls[0][0]).toEqual({
where: { slug: 'sonic' },
expect((prisma.game.findFirst as unknown as Mock).mock.calls[0][0]).toEqual({
where: { source: 'rom', romChecksum: 'md5-abc' },
});
expect((prisma.game.create as unknown as Mock).mock.calls[0][0]).toEqual({
data: { title: 'Sonic', slug: 'sonic' },
data: {
title: 'Sonic',
slug: 'sonic-1234567890123',
source: 'rom',
romPath: '/roms/Sonic.bin',
romFilename: 'Sonic.bin',
romSize: 123,
romChecksum: 'md5-abc',
romFormat: 'bin',
romHashes: JSON.stringify(hashes),
addedAt: expect.any(Date),
lastSeenAt: expect.any(Date),
},
});
expect((prisma.romFile.upsert as unknown as Mock).mock.calls.length).toBe(1);
const upsertArgs = (prisma.romFile.upsert as unknown as Mock).mock.calls[0][0];
expect(upsertArgs.where).toEqual({ checksum: 'md5-abc' });
expect(upsertArgs.create.gameId).toBe(77);
expect(upsertArgs.create.filename).toBe('Sonic.bin');
expect(upsertArgs.create.hashes).toBe(JSON.stringify(hashes));
expect(summary).toEqual({ processed: 1, createdCount: 1, upserted: 1 });
expect(summary).toEqual({ processed: 1, createdCount: 1, upserted: 0 });
});
it('cuando el juego ya existe (mismo checksum), actualiza lastSeenAt', async () => {
const files = [
{
path: '/roms/Sonic.bin',
filename: 'Sonic.bin',
name: 'Sonic.bin',
size: 123,
format: 'bin',
isArchive: false,
},
];
const hashes = { size: 123, md5: 'md5-abc', sha1: 'sha1-abc', crc32: 'abcd' };
(scanDirectory as unknown as Mock).mockResolvedValue(files);
(computeHashes as unknown as Mock).mockResolvedValue(hashes);
(prisma.game.findFirst as unknown as Mock).mockResolvedValue({
id: 77,
title: 'Sonic',
slug: 'sonic',
});
(prisma.game.update as unknown as Mock).mockResolvedValue({
id: 77,
title: 'Sonic',
slug: 'sonic',
});
const summary = await importDirectory({ dir: '/roms', persist: true });
expect((prisma.game.update as unknown as Mock).mock.calls[0][0]).toEqual({
where: { id: 77 },
data: {
lastSeenAt: expect.any(Date),
romHashes: JSON.stringify(hashes),
},
});
expect(summary).toEqual({ processed: 1, createdCount: 0, upserted: 1 });
});
it('cuando persist=false no guarda nada en la base de datos', async () => {
const files = [
{
path: '/roms/Sonic.bin',
filename: 'Sonic.bin',
name: 'Sonic.bin',
size: 123,
format: 'bin',
isArchive: false,
},
];
const hashes = { size: 123, md5: 'md5-abc', sha1: 'sha1-abc', crc32: 'abcd' };
(scanDirectory as unknown as Mock).mockResolvedValue(files);
(computeHashes as unknown as Mock).mockResolvedValue(hashes);
const summary = await importDirectory({ dir: '/roms', persist: false });
expect(prisma.game.findFirst).not.toHaveBeenCalled();
expect(prisma.game.create).not.toHaveBeenCalled();
expect(prisma.game.update).not.toHaveBeenCalled();
expect(summary).toEqual({ processed: 1, createdCount: 0, upserted: 0 });
});
});

View File

@@ -9,14 +9,18 @@
"lint": "eslint"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.575.0",
"next": "16.1.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "^3.5.0"
"react-hook-form": "^7.71.2",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View File

@@ -0,0 +1,258 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { Game, gamesApi } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ArrowLeftIcon, CalendarIcon, RefreshCwIcon, FileIcon } from 'lucide-react';
import { format } from 'date-fns';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
export default function GameDetailPage() {
const params = useParams();
const router = useRouter();
const gameId = params.id as string;
const [game, setGame] = useState<Game | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const loadGame = async () => {
setLoading(true);
setError(null);
try {
const gameData = await gamesApi.getById(gameId);
setGame(gameData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al cargar el juego');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadGame();
}, [gameId]);
const handleDeleteGame = async () => {
try {
await gamesApi.delete(gameId);
router.push('/games');
} catch (err) {
console.error('Error deleting game:', err);
}
};
const formatFileSize = (bytes?: number) => {
if (!bytes) return '-';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
};
const formatDate = (dateString?: string) => {
if (!dateString) return null;
try {
return format(new Date(dateString), 'yyyy-MM-dd');
} catch {
return null;
}
};
if (loading) {
return (
<div className="container mx-auto py-8">
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">Cargando juego...</div>
</div>
</div>
);
}
if (error || !game) {
return (
<div className="container mx-auto py-8">
<div className="flex items-center justify-center py-12">
<div className="text-destructive">{error || 'Juego no encontrado'}</div>
</div>
</div>
);
}
return (
<div className="container mx-auto py-8">
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeftIcon className="size-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">{game.title}</h1>
<p className="text-muted-foreground">{game.slug}</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={loadGame} disabled={refreshing}>
<RefreshCwIcon className={`size-4 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
<Button variant="destructive" onClick={() => setDeleteDialogOpen(true)}>
Eliminar Juego
</Button>
</div>
</div>
{/* Game Info */}
<Card>
<CardHeader>
<CardTitle>Información del Juego</CardTitle>
<CardDescription>Detalles y metadatos</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{formatDate(game.releaseDate) && (
<div className="flex items-center gap-2">
<CalendarIcon className="size-4 text-muted-foreground" />
<span className="text-sm">
<span className="text-muted-foreground">Fecha de lanzamiento:</span>{' '}
{formatDate(game.releaseDate)}
</span>
</div>
)}
<div className="flex items-center gap-2">
<Badge variant="secondary">Fuente: {game.source}</Badge>
</div>
{game.genre && (
<div className="flex items-center gap-2">
<Badge variant="outline">Género: {game.genre}</Badge>
</div>
)}
{game.platform && (
<div className="flex items-center gap-2">
<Badge variant="outline">Plataforma: {game.platform}</Badge>
</div>
)}
{game.year && (
<div className="flex items-center gap-2">
<Badge variant="outline">Año: {game.year}</Badge>
</div>
)}
{game.sourceId && (
<div className="flex items-center gap-2">
<Badge variant="outline">ID de fuente: {game.sourceId}</Badge>
</div>
)}
{game.igdbId && (
<div className="flex items-center gap-2">
<Badge variant="outline">IGDB ID: {game.igdbId}</Badge>
</div>
)}
{game.rawgId && (
<div className="flex items-center gap-2">
<Badge variant="outline">RAWG ID: {game.rawgId}</Badge>
</div>
)}
{game.thegamesdbId && (
<div className="flex items-center gap-2">
<Badge variant="outline">TheGamesDB ID: {game.thegamesdbId}</Badge>
</div>
)}
</div>
{game.description && (
<div className="mt-4">
<h3 className="font-semibold mb-2">Descripción</h3>
<p className="text-sm text-muted-foreground">{game.description}</p>
</div>
)}
</CardContent>
</Card>
{/* ROM Info (if source = rom) */}
{game.source === 'rom' && (
<Card>
<CardHeader>
<CardTitle>Información del Archivo ROM</CardTitle>
<CardDescription>Detalles del archivo importado</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{game.romFilename && (
<div className="flex items-center gap-2">
<FileIcon className="size-4 text-muted-foreground" />
<span className="text-sm">
<span className="text-muted-foreground">Archivo:</span> {game.romFilename}
</span>
</div>
)}
{game.romPath && (
<div className="flex items-center gap-2">
<FileIcon className="size-4 text-muted-foreground" />
<span className="text-sm">
<span className="text-muted-foreground">Ruta:</span> {game.romPath}
</span>
</div>
)}
{game.romSize && (
<div className="flex items-center gap-2">
<Badge variant="outline">Tamaño: {formatFileSize(game.romSize)}</Badge>
</div>
)}
{game.romFormat && (
<div className="flex items-center gap-2">
<Badge variant="outline">Formato: {game.romFormat}</Badge>
</div>
)}
{game.romChecksum && (
<div className="flex items-center gap-2">
<Badge variant="outline">Checksum: {game.romChecksum}</Badge>
</div>
)}
</div>
</CardContent>
</Card>
)}
</div>
{/* Delete Game Confirmation */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Eliminar juego?</AlertDialogTitle>
<AlertDialogDescription>
¿Estás seguro de que deseas eliminar &ldquo;{game.title}&rdquo;? Esta acción no se
puede deshacer.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteGame}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Eliminar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,219 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Game, gamesApi } from '@/lib/api';
import { GameTable } from '@/components/games/GameTable';
import { GameDialog } from '@/components/games/GameDialog';
import { GameFilters } from '@/components/games/GameFilters';
import { ImportSheet } from '@/components/games/ImportSheet';
import { Button } from '@/components/ui/button';
import { PlusIcon, LayoutGridIcon, TableIcon } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
export default function GamesPage() {
const router = useRouter();
const [games, setGames] = useState<Game[]>([]);
const [filteredGames, setFilteredGames] = useState<Game[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingGame, setEditingGame] = useState<Game | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [gameToDelete, setGameToDelete] = useState<Game | null>(null);
const [viewMode, setViewMode] = useState<'table' | 'grid'>('table');
const loadGames = async () => {
setLoading(true);
try {
const data = await gamesApi.getAll();
setGames(data);
setFilteredGames(data);
} catch (err) {
console.error('Error loading games:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadGames();
}, []);
useEffect(() => {
if (!searchQuery.trim()) {
setFilteredGames(games);
return;
}
const query = searchQuery.toLowerCase();
const filtered = games.filter(
(game) =>
game.title.toLowerCase().includes(query) || game.description?.toLowerCase().includes(query)
);
setFilteredGames(filtered);
}, [searchQuery, games]);
const handleCreate = () => {
setEditingGame(null);
setDialogOpen(true);
};
const handleEdit = (game: Game) => {
setEditingGame(game);
setDialogOpen(true);
};
const handleView = (game: Game) => {
router.push(`/games/${game.id}`);
};
const handleDelete = (game: Game) => {
setGameToDelete(game);
setDeleteDialogOpen(true);
};
const confirmDelete = async () => {
if (!gameToDelete) return;
try {
await gamesApi.delete(gameToDelete.id);
setDeleteDialogOpen(false);
setGameToDelete(null);
loadGames();
} catch (err) {
console.error('Error deleting game:', err);
}
};
return (
<div className="container mx-auto py-8">
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Gestión de Videojuegos</h1>
<p className="text-muted-foreground">
{filteredGames.length} {filteredGames.length === 1 ? 'juego' : 'juegos'} en tu
biblioteca
</p>
</div>
<div className="flex gap-2">
<ImportSheet onSuccess={loadGames} />
<Button onClick={handleCreate}>
<PlusIcon data-icon="inline-start" />
Nuevo Juego
</Button>
</div>
</div>
{/* Filters */}
<GameFilters
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onClear={() => setSearchQuery('')}
/>
{/* View Mode Toggle */}
<div className="flex gap-2 self-end">
<Button
variant={viewMode === 'table' ? 'default' : 'outline'}
size="icon"
onClick={() => setViewMode('table')}
>
<TableIcon className="size-4" />
</Button>
<Button
variant={viewMode === 'grid' ? 'default' : 'outline'}
size="icon"
onClick={() => setViewMode('grid')}
>
<LayoutGridIcon className="size-4" />
</Button>
</div>
{/* Content */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="text-muted-foreground">Cargando juegos...</div>
</div>
) : viewMode === 'table' ? (
<GameTable
games={filteredGames}
onView={handleView}
onEdit={handleEdit}
onDelete={handleDelete}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredGames.map((game) => (
<GameCard
key={game.id}
game={game}
onView={handleView}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
{/* Empty State */}
{!loading && filteredGames.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground mb-4">
{searchQuery
? 'No se encontraron juegos que coincidan con tu búsqueda.'
: 'No hay juegos en tu biblioteca.'}
</p>
{!searchQuery && (
<Button onClick={handleCreate}>
<PlusIcon data-icon="inline-start" />
Agregar primer juego
</Button>
)}
</div>
)}
</div>
{/* Dialog */}
<GameDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
game={editingGame}
onSuccess={loadGames}
/>
{/* Delete Confirmation */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>¿Eliminar juego?</AlertDialogTitle>
<AlertDialogDescription>
¿Estás seguro de que deseas eliminar "{gameToDelete?.title}"? Esta acción no se puede
deshacer.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Eliminar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,222 @@
'use client';
import { useState } from 'react';
import { ImportRequest, ImportResult, importApi } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
UploadIcon,
FolderOpenIcon,
CheckCircleIcon,
XCircleIcon,
LoaderIcon,
ArrowLeftIcon,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
export default function ImportPage() {
const router = useRouter();
const [directory, setDirectory] = useState('');
const [recursive, setRecursive] = useState(true);
const [isImporting, setIsImporting] = useState(false);
const [result, setResult] = useState<ImportResult | null>(null);
const handleImport = async () => {
if (!directory.trim()) return;
setIsImporting(true);
setResult(null);
try {
const importData: ImportRequest = {
directory: directory.trim(),
recursive,
};
const importResult = await importApi.start(importData);
setResult(importResult);
} catch (err) {
setResult({
success: false,
message: err instanceof Error ? err.message : 'Error al importar',
imported: 0,
errors: [err instanceof Error ? err.message : 'Error desconocido'],
});
} finally {
setIsImporting(false);
}
};
const handleReset = () => {
setDirectory('');
setResult(null);
};
return (
<div className="container mx-auto py-8">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeftIcon className="size-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Importar Juegos</h1>
<p className="text-muted-foreground">
Importa juegos desde archivos ROM en un directorio local
</p>
</div>
</div>
{/* Import Form */}
<Card>
<CardHeader>
<CardTitle>Configuración de Importación</CardTitle>
<CardDescription>
Especifica el directorio que contiene los archivos ROM que deseas importar
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="directory">Directorio *</Label>
<div className="flex gap-2">
<Input
id="directory"
value={directory}
onChange={(e) => setDirectory(e.target.value)}
placeholder="/path/to/roms"
disabled={isImporting}
/>
<Button
type="button"
variant="outline"
size="icon"
disabled={isImporting}
title="Seleccionar directorio"
>
<FolderOpenIcon className="size-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Ruta absoluta del directorio que contiene las ROMs
</p>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="recursive"
checked={recursive}
onCheckedChange={(checked) => setRecursive(checked === true)}
disabled={isImporting}
/>
<Label htmlFor="recursive" className="cursor-pointer">
Incluir subdirectorios recursivamente
</Label>
</div>
<div className="flex gap-2 mt-4">
<Button
variant="outline"
className="flex-1"
onClick={handleReset}
disabled={isImporting || !directory}
>
Limpiar
</Button>
<Button
className="flex-1"
onClick={handleImport}
disabled={isImporting || !directory.trim()}
>
{isImporting ? (
<>
<LoaderIcon className="size-4 animate-spin mr-2" />
Importando...
</>
) : (
<>
<UploadIcon data-icon="inline-start" />
Importar
</>
)}
</Button>
</div>
</CardContent>
</Card>
{/* Result */}
{result && (
<Card
className={`mt-6 ${result.success ? 'border-emerald-500/50' : 'border-destructive/50'}`}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{result.success ? (
<CheckCircleIcon className="size-5 text-emerald-500" />
) : (
<XCircleIcon className="size-5 text-destructive" />
)}
{result.success ? 'Importación Exitosa' : 'Error en la Importación'}
</CardTitle>
<CardDescription>{result.message}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<p className="text-sm font-medium">ROMs importadas</p>
<p className="text-2xl font-bold">{result.imported}</p>
</div>
{result.errors.length > 0 && (
<div>
<p className="text-sm font-medium mb-2">Errores encontrados</p>
<div className="bg-destructive/10 rounded-lg p-3 max-h-60 overflow-y-auto">
<ul className="text-sm text-destructive space-y-1">
{result.errors.map((error, index) => (
<li key={index} className="flex items-start gap-2">
<span className="text-destructive-foreground"></span>
<span>{error}</span>
</li>
))}
</ul>
</div>
</div>
)}
<div className="flex gap-2 pt-2">
<Button variant="outline" onClick={handleReset} className="flex-1">
Nueva Importación
</Button>
{result.success && (
<Button onClick={() => router.push('/games')} className="flex-1">
Ver Juegos
</Button>
)}
</div>
</div>
</CardContent>
</Card>
)}
{/* Info */}
<Card className="mt-6 bg-muted/50">
<CardHeader>
<CardTitle className="text-base">Información</CardTitle>
</CardHeader>
<CardContent>
<ul className="text-sm text-muted-foreground space-y-1">
<li> El sistema escaneará el directorio especificado en busca de archivos ROM</li>
<li> Se calcularán los checksums (CRC32, MD5, SHA1) para cada archivo</li>
<li> Las ROMs se asociarán automáticamente con juegos existentes si coinciden</li>
<li> Los archivos duplicados se detectarán y omitirán</li>
<li> La importación puede tardar dependiendo de la cantidad de archivos</li>
</ul>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -1,28 +1,146 @@
import Navbar from '@/components/landing/Navbar';
import Hero from '@/components/landing/Hero';
import GameGrid from '@/components/landing/GameGrid';
import Footer from '@/components/landing/Footer';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Gamepad2, HardDrive, Import, Clock, Activity, Database } from 'lucide-react';
export default function Home() {
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--mass-effect-dark)' }}>
{/* Starfield Background */}
<div className="starfield"></div>
{/* Navbar */}
<Navbar />
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b border-border">
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Gamepad2 className="w-6 h-6 text-primary" />
<h1 className="text-xl font-bold">Quasar</h1>
</div>
<Badge variant="outline">v1.0.0</Badge>
</div>
</header>
{/* Main Content */}
<main id="main-content" className="pt-16">
{/* Hero Section */}
<Hero />
<main className="container mx-auto px-4 py-8">
<div className="space-y-8">
{/* Statistics Cards */}
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Activity className="w-5 h-5" />
Estadísticas
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-3">
<CardDescription>Total de Juegos</CardDescription>
<CardTitle className="text-3xl">0</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Database className="w-4 h-4" />
<span>Base de datos</span>
</div>
</CardContent>
</Card>
{/* Game Grid Section */}
<GameGrid />
<Card>
<CardHeader className="pb-3">
<CardDescription>Juegos Importados</CardDescription>
<CardTitle className="text-3xl">0</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Database className="w-4 h-4" />
<span>Desde archivos ROM</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Última Importación</CardDescription>
<CardTitle className="text-3xl">-</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="w-4 h-4" />
<span>Sin registros</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardDescription>Estado del Sistema</CardDescription>
<CardTitle className="text-3xl">
<Badge variant="default">Activo</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Activity className="w-4 h-4" />
<span>Operativo</span>
</div>
</CardContent>
</Card>
</div>
</section>
{/* Quick Actions */}
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Import className="w-5 h-5" />
Acciones Rápidas
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>Gestión de Juegos</CardTitle>
<CardDescription>Ver y administrar tu biblioteca de videojuegos</CardDescription>
</CardHeader>
<CardContent>
<Link href="/games">
<Button className="w-full">
<Gamepad2 className="w-4 h-4 mr-2" />
Ir a Juegos
</Button>
</Link>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Importar Juegos</CardTitle>
<CardDescription>Importa juegos desde archivos ROM</CardDescription>
</CardHeader>
<CardContent>
<Link href="/import">
<Button className="w-full" variant="outline">
<Import className="w-4 h-4 mr-2" />
Importar Archivos
</Button>
</Link>
</CardContent>
</Card>
</div>
</section>
{/* Recent Activity */}
<section>
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Clock className="w-5 h-5" />
Actividad Reciente
</h2>
<Card>
<CardContent className="py-6">
<div className="text-center text-muted-foreground py-8">
<Activity className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No hay actividad reciente</p>
<p className="text-sm">Las importaciones y cambios aparecerán aquí</p>
</div>
</CardContent>
</Card>
</section>
</div>
</main>
{/* Footer */}
<Footer />
</div>
);
}

View File

@@ -0,0 +1,85 @@
'use client';
import { Game } from '@/lib/api';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { EyeIcon, PencilIcon, TrashIcon, CalendarIcon } from 'lucide-react';
import { format } from 'date-fns';
interface GameCardProps {
game: Game;
onView: (game: Game) => void;
onEdit: (game: Game) => void;
onDelete: (game: Game) => void;
}
export function GameCard({ game, onView, onEdit, onDelete }: GameCardProps) {
const formatDate = (dateString?: string) => {
if (!dateString) return null;
try {
return format(new Date(dateString), 'yyyy');
} catch {
return null;
}
};
return (
<Card className="flex flex-col h-full hover:border-primary transition-colors">
<CardHeader>
<CardTitle className="line-clamp-2">{game.title}</CardTitle>
<CardDescription className="line-clamp-3">
{game.description || 'Sin descripción'}
</CardDescription>
</CardHeader>
<CardContent className="flex-1">
<div className="flex flex-wrap gap-2">
{formatDate(game.releaseDate) && (
<Badge variant="outline" className="gap-1">
<CalendarIcon className="size-3" />
{formatDate(game.releaseDate)}
</Badge>
)}
<Badge variant="secondary">{game.source}</Badge>
{game.genre && <Badge variant="outline">{game.genre}</Badge>}
</div>
</CardContent>
<CardFooter className="flex gap-2">
<Button
variant="ghost"
size="icon"
className="flex-1"
onClick={() => onView(game)}
title="Ver detalles"
>
<EyeIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="flex-1"
onClick={() => onEdit(game)}
title="Editar"
>
<PencilIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="flex-1"
onClick={() => onDelete(game)}
title="Eliminar"
>
<TrashIcon className="size-4 text-destructive" />
</Button>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,272 @@
'use client';
import { useState, useEffect } from 'react';
import { Game, CreateGameInput, UpdateGameInput, gamesApi } from '@/lib/api';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface GameDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
game?: Game | null;
onSuccess: () => void;
}
export function GameDialog({ open, onOpenChange, game, onSuccess }: GameDialogProps) {
const [formData, setFormData] = useState<CreateGameInput>({
title: '',
slug: '',
description: '',
releaseDate: '',
genre: '',
platform: '',
year: undefined,
cover: '',
source: 'manual',
sourceId: '',
platformId: '',
priceCents: undefined,
currency: 'USD',
store: '',
date: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (game) {
setFormData({
title: game.title,
slug: game.slug,
description: game.description || '',
releaseDate: game.releaseDate ? game.releaseDate.split('T')[0] : '',
genre: game.genre || '',
platform: game.platform || '',
year: game.year,
cover: game.cover || '',
source: game.source,
sourceId: game.sourceId || '',
platformId: '',
priceCents: undefined,
currency: 'USD',
store: '',
date: '',
});
} else {
setFormData({
title: '',
slug: '',
description: '',
releaseDate: '',
genre: '',
platform: '',
year: undefined,
cover: '',
source: 'manual',
sourceId: '',
platformId: '',
priceCents: undefined,
currency: 'USD',
store: '',
date: '',
});
}
setError(null);
}, [game, open]);
const handleChange = (field: keyof CreateGameInput, value: string | number | undefined) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const generateSlug = () => {
const slug = formData.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
setFormData((prev) => ({ ...prev, slug }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
if (game) {
await gamesApi.update(game.id, formData as UpdateGameInput);
} else {
await gamesApi.create(formData);
}
onSuccess();
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al guardar el juego');
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>{game ? 'Editar Juego' : 'Nuevo Juego'}</DialogTitle>
<DialogDescription>
{game ? 'Edita la información del juego.' : 'Añade un nuevo juego a tu biblioteca.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="flex flex-col gap-4 py-4">
<div className="flex flex-col gap-2">
<Label htmlFor="title">Título *</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => handleChange('title', e.target.value)}
onBlur={generateSlug}
required
placeholder="Ej: Super Mario World"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="slug">Slug</Label>
<div className="flex gap-2">
<Input
id="slug"
value={formData.slug}
onChange={(e) => handleChange('slug', e.target.value)}
placeholder="super-mario-world"
/>
<Button
type="button"
variant="outline"
onClick={generateSlug}
disabled={!formData.title}
>
Generar
</Button>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="description">Descripción</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => handleChange('description', e.target.value)}
placeholder="Descripción del juego..."
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="releaseDate">Fecha de lanzamiento</Label>
<Input
id="releaseDate"
type="date"
value={formData.releaseDate}
onChange={(e) => handleChange('releaseDate', e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="year">Año</Label>
<Input
id="year"
type="number"
value={formData.year || ''}
onChange={(e) =>
handleChange('year', e.target.value ? parseInt(e.target.value) : undefined)
}
placeholder="1990"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="genre">Género</Label>
<Input
id="genre"
value={formData.genre}
onChange={(e) => handleChange('genre', e.target.value)}
placeholder="Plataformas"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="platform">Plataforma</Label>
<Input
id="platform"
value={formData.platform}
onChange={(e) => handleChange('platform', e.target.value)}
placeholder="SNES"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="cover">URL de la portada</Label>
<Input
id="cover"
value={formData.cover}
onChange={(e) => handleChange('cover', e.target.value)}
placeholder="https://example.com/cover.jpg"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="source">Fuente</Label>
<Select
value={formData.source}
onValueChange={(value) => handleChange('source', value)}
>
<SelectTrigger>
<SelectValue placeholder="Selecciona fuente" />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="rom">ROM</SelectItem>
<SelectItem value="igdb">IGDB</SelectItem>
<SelectItem value="rawg">RAWG</SelectItem>
<SelectItem value="thegamesdb">TheGamesDB</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="sourceId">ID de fuente externa</Label>
<Input
id="sourceId"
value={formData.sourceId}
onChange={(e) => handleChange('sourceId', e.target.value)}
placeholder="ID en la fuente externa"
/>
</div>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancelar
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Guardando...' : game ? 'Actualizar' : 'Crear'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { SearchIcon, XIcon } from 'lucide-react';
interface GameFiltersProps {
searchQuery: string;
onSearchChange: (query: string) => void;
onClear: () => void;
}
export function GameFilters({ searchQuery, onSearchChange, onClear }: GameFiltersProps) {
return (
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-md">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Buscar juegos..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 size-6"
onClick={onClear}
>
<XIcon className="size-4" />
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import { useState } from 'react';
import { Game } from '@/lib/api';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { EyeIcon, PencilIcon, TrashIcon } from 'lucide-react';
import { format } from 'date-fns';
interface GameTableProps {
games: Game[];
onView: (game: Game) => void;
onEdit: (game: Game) => void;
onDelete: (game: Game) => void;
}
export function GameTable({ games, onView, onEdit, onDelete }: GameTableProps) {
const [sortField, setSortField] = useState<keyof Game>('title');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const handleSort = (field: keyof Game) => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const sortedGames = [...games].sort((a, b) => {
const aVal = a[sortField] || '';
const bVal = b[sortField] || '';
if (typeof aVal === 'string' && typeof bVal === 'string') {
return sortDirection === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
}
return 0;
});
const formatDate = (dateString?: string) => {
if (!dateString) return '-';
try {
return format(new Date(dateString), 'yyyy-MM-dd');
} catch {
return '-';
}
};
return (
<div className="rounded-md border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead
className="cursor-pointer hover:text-primary"
onClick={() => handleSort('title')}
>
Título {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')}
</TableHead>
<TableHead>Descripción</TableHead>
<TableHead
className="cursor-pointer hover:text-primary"
onClick={() => handleSort('releaseDate')}
>
Fecha {sortField === 'releaseDate' && (sortDirection === 'asc' ? '↑' : '↓')}
</TableHead>
<TableHead>Fuente</TableHead>
<TableHead>Acciones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedGames.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
No hay juegos encontrados
</TableCell>
</TableRow>
) : (
sortedGames.map((game) => (
<TableRow key={game.id}>
<TableCell className="font-medium">{game.title}</TableCell>
<TableCell className="max-w-xs truncate text-muted-foreground">
{game.description || '-'}
</TableCell>
<TableCell>{formatDate(game.releaseDate)}</TableCell>
<TableCell>
<Badge variant="secondary">{game.source}</Badge>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => onView(game)}
title="Ver detalles"
>
<EyeIcon className="size-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => onEdit(game)} title="Editar">
<PencilIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(game)}
title="Eliminar"
>
<TrashIcon className="size-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,172 @@
'use client';
import { useState } from 'react';
import { ImportRequest, ImportResult, importApi } from '@/lib/api';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { UploadIcon, FolderOpenIcon, CheckCircleIcon, XCircleIcon, LoaderIcon } from 'lucide-react';
interface ImportSheetProps {
onSuccess: () => void;
}
export function ImportSheet({ onSuccess }: ImportSheetProps) {
const [open, setOpen] = useState(false);
const [directory, setDirectory] = useState('');
const [recursive, setRecursive] = useState(true);
const [isImporting, setIsImporting] = useState(false);
const [result, setResult] = useState<ImportResult | null>(null);
const handleImport = async () => {
if (!directory.trim()) return;
setIsImporting(true);
setResult(null);
try {
const importData: ImportRequest = {
directory: directory.trim(),
recursive,
};
const importResult = await importApi.start(importData);
setResult(importResult);
if (importResult.success) {
onSuccess();
}
} catch (err) {
setResult({
success: false,
message: err instanceof Error ? err.message : 'Error al importar',
imported: 0,
errors: [err instanceof Error ? err.message : 'Error desconocido'],
});
} finally {
setIsImporting(false);
}
};
const handleClose = () => {
setOpen(false);
setDirectory('');
setResult(null);
};
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button>
<UploadIcon data-icon="inline-start" />
Importar Juegos
</Button>
</SheetTrigger>
<SheetContent className="sm:max-w-md">
<SheetHeader>
<SheetTitle>Importar Juegos</SheetTitle>
<SheetDescription>
Importa juegos desde archivos ROM en un directorio local.
</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-4 py-4">
<div className="flex flex-col gap-2">
<Label htmlFor="directory">Directorio</Label>
<div className="flex gap-2">
<Input
id="directory"
value={directory}
onChange={(e) => setDirectory(e.target.value)}
placeholder="/path/to/roms"
disabled={isImporting}
/>
<Button
type="button"
variant="outline"
size="icon"
disabled={isImporting}
title="Seleccionar directorio"
>
<FolderOpenIcon className="size-4" />
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="recursive"
checked={recursive}
onCheckedChange={(checked) => setRecursive(checked === true)}
disabled={isImporting}
/>
<Label htmlFor="recursive" className="cursor-pointer">
Incluir subdirectorios
</Label>
</div>
{result && (
<div className="rounded-lg border border-border p-4">
<div className="flex items-center gap-2 mb-3">
{result.success ? (
<CheckCircleIcon className="size-5 text-emerald-500" />
) : (
<XCircleIcon className="size-5 text-destructive" />
)}
<span className="font-medium">
{result.success ? 'Importación completada' : 'Error en la importación'}
</span>
</div>
<p className="text-sm text-muted-foreground mb-2">{result.message}</p>
<p className="text-sm font-medium">ROMs importadas: {result.imported}</p>
{result.errors.length > 0 && (
<div className="mt-3">
<p className="text-sm font-medium mb-1">Errores:</p>
<ul className="text-sm text-destructive list-disc list-inside">
{result.errors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
)}
</div>
)}
{isImporting && (
<div className="flex items-center gap-2 text-muted-foreground">
<LoaderIcon className="size-4 animate-spin" />
<span>Importando ROMs...</span>
</div>
)}
<div className="flex gap-2 mt-auto">
<Button
variant="outline"
className="flex-1"
onClick={handleClose}
disabled={isImporting}
>
Cancelar
</Button>
<Button
className="flex-1"
onClick={handleImport}
disabled={isImporting || !directory.trim()}
>
{isImporting ? 'Importando...' : 'Importar'}
</Button>
</div>
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -42,32 +42,19 @@ const Navbar = () => {
{/* Navigation Links - Desktop */}
<div className="hidden md:flex items-center space-x-6">
<a
href="#"
href="/games"
className="text-white hover:text-glow-cyan transition-colors"
style={{ textShadow: '0 0 5px var(--mass-effect-cyan-glow)' }}
>
GAMES
</a>
<a
href="#"
href="/import"
className="text-white hover:text-glow-cyan transition-colors"
style={{ textShadow: '0 0 5px var(--mass-effect-cyan-glow)' }}
>
LIBRARY
IMPORT
</a>
<a
href="#"
className="text-white hover:text-glow-cyan transition-colors"
style={{ textShadow: '0 0 5px var(--mass-effect-cyan-glow)' }}
>
STATS
</a>
<Button
variant="outline"
className="border-cyan-500 text-cyan-500 hover:bg-cyan-500 hover:text-black"
>
LOGIN
</Button>
</div>
{/* Mobile Menu Button */}
@@ -123,32 +110,19 @@ const Navbar = () => {
{/* Navigation Links - Mobile */}
<div className="flex flex-col space-y-3">
<a
href="#"
href="/games"
className="text-white hover:text-glow-cyan transition-colors py-2"
tabIndex={isMenuOpen ? 0 : -1}
>
GAMES
</a>
<a
href="#"
href="/import"
className="text-white hover:text-glow-cyan transition-colors py-2"
tabIndex={isMenuOpen ? 0 : -1}
>
LIBRARY
IMPORT
</a>
<a
href="#"
className="text-white hover:text-glow-cyan transition-colors py-2"
tabIndex={isMenuOpen ? 0 : -1}
>
STATS
</a>
<Button
variant="outline"
className="border-cyan-500 text-cyan-500 hover:bg-cyan-500 hover:text-black w-full"
>
LOGIN
</Button>
</div>
</div>
)}

View File

@@ -0,0 +1,117 @@
'use client';
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

189
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,189 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api';
export interface Game {
id: string;
title: string;
slug: string;
description?: string;
releaseDate?: string;
genre?: string;
platform?: string;
year?: number;
cover?: string;
source: string;
sourceId?: string;
// Campos específicos de ROM (si source = "rom")
romPath?: string;
romFilename?: string;
romSize?: number;
romChecksum?: string;
romFormat?: string;
romHashes?: string;
// IDs de integraciones externas
igdbId?: number;
rawgId?: number;
thegamesdbId?: number;
// Metadatos adicionales
metadata?: string;
// Timestamps
addedAt?: string;
lastSeenAt?: string;
createdAt: string;
updatedAt: string;
// Relaciones
artworks?: any[];
purchases?: any[];
gamePlatforms?: any[];
tags?: any[];
}
export interface CreateGameInput {
title: string;
slug?: string;
description?: string;
releaseDate?: string;
genre?: string;
platform?: string;
year?: number;
cover?: string;
source?: string;
sourceId?: string;
platformId?: string;
priceCents?: number;
currency?: string;
store?: string;
date?: string;
condition?: 'Loose' | 'CIB' | 'New';
}
export interface UpdateGameInput {
title?: string;
slug?: string;
description?: string;
releaseDate?: string;
genre?: string;
platform?: string;
year?: number;
cover?: string;
source?: string;
sourceId?: string;
platformId?: string;
priceCents?: number;
currency?: string;
store?: string;
date?: string;
condition?: 'Loose' | 'CIB' | 'New';
}
export interface ImportRequest {
directory: string;
recursive?: boolean;
}
export interface ImportResult {
success: boolean;
message: string;
imported: number;
errors: string[];
}
export const gamesApi = {
getAll: async (): Promise<Game[]> => {
const response = await fetch(`${API_BASE}/games`);
if (!response.ok) {
throw new Error(`Error fetching games: ${response.statusText}`);
}
return response.json();
},
getById: async (id: string): Promise<Game> => {
const response = await fetch(`${API_BASE}/games/${id}`);
if (!response.ok) {
throw new Error(`Error fetching game: ${response.statusText}`);
}
return response.json();
},
getBySource: async (source: string): Promise<Game[]> => {
const response = await fetch(`${API_BASE}/games/source/${source}`);
if (!response.ok) {
throw new Error(`Error fetching games by source: ${response.statusText}`);
}
return response.json();
},
create: async (data: CreateGameInput): Promise<Game> => {
const response = await fetch(`${API_BASE}/games`, {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error(`Error creating game: ${response.statusText}`);
}
return response.json();
},
update: async (id: string, data: UpdateGameInput): Promise<Game> => {
const response = await fetch(`${API_BASE}/games/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error(`Error updating game: ${response.statusText}`);
}
return response.json();
},
delete: async (id: string): Promise<void> => {
const response = await fetch(`${API_BASE}/games/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`Error deleting game: ${response.statusText}`);
}
},
};
export const importApi = {
start: async (data: ImportRequest): Promise<ImportResult> => {
const response = await fetch(`${API_BASE}/import`, {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error(`Error starting import: ${response.statusText}`);
}
return response.json();
},
};
export const metadataApi = {
searchIGDB: async (query: string): Promise<any[]> => {
const response = await fetch(`${API_BASE}/metadata/igdb/search?q=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error(`Error searching IGDB: ${response.statusText}`);
}
return response.json();
},
searchRAWG: async (query: string): Promise<any[]> => {
const response = await fetch(`${API_BASE}/metadata/rawg/search?q=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error(`Error searching RAWG: ${response.statusText}`);
}
return response.json();
},
searchTheGamesDB: async (query: string): Promise<any[]> => {
const response = await fetch(
`${API_BASE}/metadata/thegamesdb/search?q=${encodeURIComponent(query)}`
);
if (!response.ok) {
throw new Error(`Error searching TheGamesDB: ${response.statusText}`);
}
return response.json();
},
};

15
skills-lock.json Normal file
View File

@@ -0,0 +1,15 @@
{
"version": 1,
"skills": {
"frontend-design": {
"source": "mager/frontend-design",
"sourceType": "github",
"computedHash": "932ef23b5d0a0aa4a89e51a9bde1217fb987d35fe2c1d5fe51c3090938ac4bd8"
},
"shadcn": {
"source": "shadcn/ui",
"sourceType": "github",
"computedHash": "91c02eb706f046fb9d6a061efbb96496e4be751b5b14c283cf82baaa50f3a985"
}
}
}

View File

@@ -1045,6 +1045,17 @@ __metadata:
languageName: node
linkType: hard
"@hookform/resolvers@npm:^5.2.2":
version: 5.2.2
resolution: "@hookform/resolvers@npm:5.2.2"
dependencies:
"@standard-schema/utils": "npm:^0.3.0"
peerDependencies:
react-hook-form: ^7.55.0
checksum: 10c0/0692cd61dcc2a70cbb27b88a37f733c39e97f555c036ba04a81bd42b0467461cfb6bafacb46c16f173672f9c8a216bd7928a2330d4e49c700d130622bf1defaf
languageName: node
linkType: hard
"@humanfs/core@npm:^0.19.1":
version: 0.19.1
resolution: "@humanfs/core@npm:0.19.1"
@@ -3325,6 +3336,13 @@ __metadata:
languageName: node
linkType: hard
"@standard-schema/utils@npm:^0.3.0":
version: 0.3.0
resolution: "@standard-schema/utils@npm:0.3.0"
checksum: 10c0/6eb74cd13e52d5fc74054df51e37d947ef53f3ab9e02c085665dcca3c38c60ece8d735cebbdf18fbb13c775fbcb9becb3f53109b0e092a63f0f7389ce0993fd0
languageName: node
linkType: hard
"@swc/helpers@npm:0.5.15":
version: 0.5.15
resolution: "@swc/helpers@npm:0.5.15"
@@ -5222,6 +5240,13 @@ __metadata:
languageName: node
linkType: hard
"date-fns@npm:^4.1.0":
version: 4.1.0
resolution: "date-fns@npm:4.1.0"
checksum: 10c0/b79ff32830e6b7faa009590af6ae0fb8c3fd9ffad46d930548fbb5acf473773b4712ae887e156ba91a7b3dc30591ce0f517d69fd83bd9c38650fdc03b4e0bac8
languageName: node
linkType: hard
"date-time@npm:^3.1.0":
version: 3.1.0
resolution: "date-time@npm:3.1.0"
@@ -6820,12 +6845,14 @@ __metadata:
version: 0.0.0-use.local
resolution: "frontend@workspace:frontend"
dependencies:
"@hookform/resolvers": "npm:^5.2.2"
"@tailwindcss/postcss": "npm:^4"
"@types/node": "npm:^20"
"@types/react": "npm:^19"
"@types/react-dom": "npm:^19"
class-variance-authority: "npm:^0.7.1"
clsx: "npm:^2.1.1"
date-fns: "npm:^4.1.0"
eslint: "npm:^9"
eslint-config-next: "npm:16.1.6"
lucide-react: "npm:^0.575.0"
@@ -6833,11 +6860,13 @@ __metadata:
radix-ui: "npm:^1.4.3"
react: "npm:19.2.3"
react-dom: "npm:19.2.3"
react-hook-form: "npm:^7.71.2"
shadcn: "npm:^3.8.5"
tailwind-merge: "npm:^3.5.0"
tailwindcss: "npm:^4"
tw-animate-css: "npm:^1.4.0"
typescript: "npm:^5"
zod: "npm:^4.3.6"
languageName: unknown
linkType: soft
@@ -9709,6 +9738,15 @@ __metadata:
languageName: node
linkType: hard
"react-hook-form@npm:^7.71.2":
version: 7.71.2
resolution: "react-hook-form@npm:7.71.2"
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
checksum: 10c0/bff61c229f5f2516e650325e0c450146cb8a08dc323e5e9aa0753eb937be0b66fe17f40a7b4be9aabc2b5cb770d03de07db7f4912c6c9452a86924072cc1cb46
languageName: node
linkType: hard
"react-is@npm:^16.13.1":
version: 16.13.1
resolution: "react-is@npm:16.13.1"
@@ -12158,7 +12196,7 @@ __metadata:
languageName: node
linkType: hard
"zod@npm:^3.25 || ^4.0, zod@npm:^3.25.0 || ^4.0.0":
"zod@npm:^3.25 || ^4.0, zod@npm:^3.25.0 || ^4.0.0, zod@npm:^4.3.6":
version: 4.3.6
resolution: "zod@npm:4.3.6"
checksum: 10c0/860d25a81ab41d33aa25f8d0d07b091a04acb426e605f396227a796e9e800c44723ed96d0f53a512b57be3d1520f45bf69c0cb3b378a232a00787a2609625307