feat: add UI components for alert dialog, badge, checkbox, dialog, label, select, sheet, table, textarea
- 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:
35
.agents/skills/frontend-design/README.md
Normal file
35
.agents/skills/frontend-design/README.md
Normal 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
|
||||
84
.agents/skills/frontend-design/SKILL.md
Normal file
84
.agents/skills/frontend-design/SKILL.md
Normal 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
|
||||
240
.agents/skills/shadcn/SKILL.md
Normal file
240
.agents/skills/shadcn/SKILL.md
Normal 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 (2–7 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 2–5 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
|
||||
5
.agents/skills/shadcn/agents/openai.yml
Normal file
5
.agents/skills/shadcn/agents/openai.yml
Normal 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"
|
||||
BIN
.agents/skills/shadcn/assets/shadcn-small.png
Normal file
BIN
.agents/skills/shadcn/assets/shadcn-small.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
.agents/skills/shadcn/assets/shadcn.png
Normal file
BIN
.agents/skills/shadcn/assets/shadcn.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
255
.agents/skills/shadcn/cli.md
Normal file
255
.agents/skills/shadcn/cli.md
Normal 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.
|
||||
202
.agents/skills/shadcn/customization.md
Normal file
202
.agents/skills/shadcn/customization.md
Normal 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 (0–1), chroma (0 = gray), and hue (0–360).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
47
.agents/skills/shadcn/evals/evals.json
Normal file
47
.agents/skills/shadcn/evals/evals.json
Normal 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-*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
94
.agents/skills/shadcn/mcp.md
Normal file
94
.agents/skills/shadcn/mcp.md
Normal 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`
|
||||
306
.agents/skills/shadcn/rules/base-vs-radix.md
Normal file
306
.agents/skills/shadcn/rules/base-vs-radix.md
Normal 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>
|
||||
```
|
||||
195
.agents/skills/shadcn/rules/composition.md
Normal file
195
.agents/skills/shadcn/rules/composition.md
Normal 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">` |
|
||||
192
.agents/skills/shadcn/rules/forms.md
Normal file
192
.agents/skills/shadcn/rules/forms.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# Forms & Inputs
|
||||
|
||||
## Contents
|
||||
|
||||
- Forms use FieldGroup + Field
|
||||
- InputGroup requires InputGroupInput/InputGroupTextarea
|
||||
- Buttons inside inputs use InputGroup + InputGroupAddon
|
||||
- Option sets (2–7 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 2–5 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 (2–7 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`.
|
||||
101
.agents/skills/shadcn/rules/icons.md
Normal file
101
.agents/skills/shadcn/rules/icons.md
Normal 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} />
|
||||
```
|
||||
162
.agents/skills/shadcn/rules/styling.md
Normal file
162
.agents/skills/shadcn/rules/styling.md
Normal 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
1
.roo/skills/frontend-design
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/frontend-design
|
||||
1
.roo/skills/shadcn
Symbolic link
1
.roo/skills/shadcn
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/shadcn
|
||||
32
backend/dist/src/app.js
vendored
Normal file
32
backend/dist/src/app.js
vendored
Normal 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
10
backend/dist/src/config.js
vendored
Normal 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;
|
||||
212
backend/dist/src/controllers/gamesController.js
vendored
Normal file
212
backend/dist/src/controllers/gamesController.js
vendored
Normal 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
30
backend/dist/src/index.js
vendored
Normal 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
116
backend/dist/src/jobs/importRunner.js
vendored
Normal 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;
|
||||
19
backend/dist/src/lib/fileTypeDetector.js
vendored
Normal file
19
backend/dist/src/lib/fileTypeDetector.js
vendored
Normal 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
12
backend/dist/src/plugins/prisma.js
vendored
Normal 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
113
backend/dist/src/routes/games.js
vendored
Normal 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
11
backend/dist/src/routes/health.js
vendored
Normal 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
24
backend/dist/src/routes/import.js
vendored
Normal 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
77
backend/dist/src/routes/metadata.js
vendored
Normal 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
|
||||
*/
|
||||
166
backend/dist/src/services/archiveReader.js
vendored
Normal file
166
backend/dist/src/services/archiveReader.js
vendored
Normal 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 };
|
||||
116
backend/dist/src/services/checksumService.js
vendored
Normal file
116
backend/dist/src/services/checksumService.js
vendored
Normal 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;
|
||||
72
backend/dist/src/services/datVerifier.js
vendored
Normal file
72
backend/dist/src/services/datVerifier.js
vendored
Normal 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
96
backend/dist/src/services/fsScanner.js
vendored
Normal 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
116
backend/dist/src/services/igdbClient.js
vendored
Normal 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
|
||||
*/
|
||||
132
backend/dist/src/services/importService.js
vendored
Normal file
132
backend/dist/src/services/importService.js
vendored
Normal 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;
|
||||
98
backend/dist/src/services/metadataService.js
vendored
Normal file
98
backend/dist/src/services/metadataService.js
vendored
Normal 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
72
backend/dist/src/services/rawgClient.js
vendored
Normal 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
|
||||
*/
|
||||
85
backend/dist/src/services/thegamesdbClient.js
vendored
Normal file
85
backend/dist/src/services/thegamesdbClient.js
vendored
Normal 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
|
||||
*/
|
||||
41
backend/dist/src/validators/gameValidator.js
vendored
Normal file
41
backend/dist/src/validators/gameValidator.js
vendored
Normal 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
|
||||
*/
|
||||
13
backend/dist/src/validators/romValidator.js
vendored
Normal file
13
backend/dist/src/validators/romValidator.js
vendored
Normal 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
|
||||
*/
|
||||
90
backend/dist/tests/jobs/importRunner.spec.js
vendored
Normal file
90
backend/dist/tests/jobs/importRunner.spec.js
vendored
Normal 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
89
backend/dist/tests/models/game.spec.js
vendored
Normal 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
228
backend/dist/tests/routes/games.spec.js
vendored
Normal 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
|
||||
*/
|
||||
17
backend/dist/tests/routes/import.spec.js
vendored
Normal file
17
backend/dist/tests/routes/import.spec.js
vendored
Normal 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();
|
||||
});
|
||||
});
|
||||
117
backend/dist/tests/routes/metadata.spec.js
vendored
Normal file
117
backend/dist/tests/routes/metadata.spec.js
vendored
Normal 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
255
backend/dist/tests/routes/roms.spec.js
vendored
Normal 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
19
backend/dist/tests/server.spec.js
vendored
Normal 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
|
||||
*/
|
||||
55
backend/dist/tests/services/archiveReader.integration.spec.js
vendored
Normal file
55
backend/dist/tests/services/archiveReader.integration.spec.js
vendored
Normal 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 });
|
||||
});
|
||||
}
|
||||
75
backend/dist/tests/services/archiveReader.spec.js
vendored
Normal file
75
backend/dist/tests/services/archiveReader.spec.js
vendored
Normal 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([]);
|
||||
});
|
||||
});
|
||||
89
backend/dist/tests/services/archiveReader.stream.spec.js
vendored
Normal file
89
backend/dist/tests/services/archiveReader.stream.spec.js
vendored
Normal 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();
|
||||
});
|
||||
});
|
||||
23
backend/dist/tests/services/checksumService.spec.js
vendored
Normal file
23
backend/dist/tests/services/checksumService.spec.js
vendored
Normal 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();
|
||||
});
|
||||
});
|
||||
23
backend/dist/tests/services/checksumService.stream.spec.js
vendored
Normal file
23
backend/dist/tests/services/checksumService.stream.spec.js
vendored
Normal 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 });
|
||||
});
|
||||
});
|
||||
64
backend/dist/tests/services/datVerifier.spec.js
vendored
Normal file
64
backend/dist/tests/services/datVerifier.spec.js
vendored
Normal 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();
|
||||
});
|
||||
});
|
||||
69
backend/dist/tests/services/fsScanner.archiveEntries.spec.js
vendored
Normal file
69
backend/dist/tests/services/fsScanner.archiveEntries.spec.js
vendored
Normal 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 });
|
||||
});
|
||||
27
backend/dist/tests/services/fsScanner.spec.js
vendored
Normal file
27
backend/dist/tests/services/fsScanner.spec.js
vendored
Normal 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();
|
||||
});
|
||||
});
|
||||
71
backend/dist/tests/services/importService.archiveEntry.spec.js
vendored
Normal file
71
backend/dist/tests/services/importService.archiveEntry.spec.js
vendored
Normal 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 });
|
||||
});
|
||||
});
|
||||
127
backend/dist/tests/services/importService.spec.js
vendored
Normal file
127
backend/dist/tests/services/importService.spec.js
vendored
Normal 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 });
|
||||
});
|
||||
});
|
||||
103
backend/dist/tests/services/metadataService.spec.js
vendored
Normal file
103
backend/dist/tests/services/metadataService.spec.js
vendored
Normal 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
24
backend/dist/tests/setup.js
vendored
Normal 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
|
||||
*/
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
258
frontend/src/app/games/[id]/page.tsx
Normal file
258
frontend/src/app/games/[id]/page.tsx
Normal 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 “{game.title}”? 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>
|
||||
);
|
||||
}
|
||||
219
frontend/src/app/games/page.tsx
Normal file
219
frontend/src/app/games/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
222
frontend/src/app/import/page.tsx
Normal file
222
frontend/src/app/import/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
85
frontend/src/components/games/GameCard.tsx
Normal file
85
frontend/src/components/games/GameCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
272
frontend/src/components/games/GameDialog.tsx
Normal file
272
frontend/src/components/games/GameDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
frontend/src/components/games/GameFilters.tsx
Normal file
37
frontend/src/components/games/GameFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
frontend/src/components/games/GameTable.tsx
Normal file
128
frontend/src/components/games/GameTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
frontend/src/components/games/ImportSheet.tsx
Normal file
172
frontend/src/components/games/ImportSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
117
frontend/src/components/ui/alert-dialog.tsx
Normal file
117
frontend/src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
};
|
||||
48
frontend/src/components/ui/badge.tsx
Normal file
48
frontend/src/components/ui/badge.tsx
Normal 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 }
|
||||
32
frontend/src/components/ui/checkbox.tsx
Normal file
32
frontend/src/components/ui/checkbox.tsx
Normal 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 }
|
||||
158
frontend/src/components/ui/dialog.tsx
Normal file
158
frontend/src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
24
frontend/src/components/ui/label.tsx
Normal file
24
frontend/src/components/ui/label.tsx
Normal 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 }
|
||||
190
frontend/src/components/ui/select.tsx
Normal file
190
frontend/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
143
frontend/src/components/ui/sheet.tsx
Normal file
143
frontend/src/components/ui/sheet.tsx
Normal 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,
|
||||
}
|
||||
116
frontend/src/components/ui/table.tsx
Normal file
116
frontend/src/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
18
frontend/src/components/ui/textarea.tsx
Normal file
18
frontend/src/components/ui/textarea.tsx
Normal 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
189
frontend/src/lib/api.ts
Normal 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
15
skills-lock.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
40
yarn.lock
40
yarn.lock
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user