Compare commits
8 Commits
d6660e0b26
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a768e477ec | ||
|
|
c88af8391d | ||
|
|
c11d9f5a1f | ||
|
|
bda56b7e7e | ||
|
|
aa6496de59 | ||
|
|
52a096d934 | ||
|
|
574d082100 | ||
|
|
50cafa8341 |
@@ -1,260 +0,0 @@
|
|||||||
---
|
|
||||||
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
|
|
||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
|
||||||
---
|
|
||||||
|
|
||||||
# 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`
|
|
||||||
```
|
|
||||||
|
|
||||||
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 preset codes or build preset URLs manually.** Use `npx shadcn@latest preset decode <code>`, `preset url <code>`, or `preset open <code>`. For project-aware preset detection, use `npx shadcn@latest preset resolve`.
|
|
||||||
- **Apply preset codes directly with the CLI.** Use `npx shadcn@latest apply <code>` for existing projects, or `npx shadcn@latest init --preset <code>` when initializing.
|
|
||||||
|
|
||||||
## 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`).
|
|
||||||
- **`preset`** → resolved preset code and values for the current project. Use `npx shadcn@latest preset resolve --json` when you only need preset information.
|
|
||||||
|
|
||||||
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: **overwrite**, **partial**, **merge**, or **skip**?
|
|
||||||
- **Inspect current preset**: `npx shadcn@latest preset resolve`. Use `--json` when you need structured values.
|
|
||||||
- **Inspect incoming preset**: `npx shadcn@latest preset decode <code>`. Use `preset url <code>` or `preset open <code>` to share or open the preset builder.
|
|
||||||
- **Overwrite**: `npx shadcn@latest apply <code>`. Overwrites detected components, fonts, and CSS variables.
|
|
||||||
- **Partial**: `npx shadcn@latest apply <code> --only theme,font`. Updates only the selected preset parts without reinstalling UI components. Supported values are `theme` and `font`; comma-separated combinations are allowed. `icon` is intentionally not supported, because icon changes may require full component reinstall and transforms.
|
|
||||||
- **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.
|
|
||||||
- **Important**: Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
|
||||||
|
|
||||||
## 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=nova (base style implied)
|
|
||||||
|
|
||||||
# Apply a preset to an existing project.
|
|
||||||
npx shadcn@latest apply a2r6bw
|
|
||||||
npx shadcn@latest apply a2r6bw --only theme
|
|
||||||
npx shadcn@latest apply a2r6bw --only font
|
|
||||||
npx shadcn@latest apply a2r6bw --only theme,font
|
|
||||||
|
|
||||||
# Inspect preset codes and project preset state.
|
|
||||||
npx shadcn@latest preset decode a2r6bw
|
|
||||||
npx shadcn@latest preset url a2r6bw
|
|
||||||
npx shadcn@latest preset open a2r6bw
|
|
||||||
npx shadcn@latest preset resolve
|
|
||||||
npx shadcn@latest preset resolve --json
|
|
||||||
|
|
||||||
# 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:** `nova`, `vega`, `maia`, `lyra`, `mira`, `luma`
|
|
||||||
**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo)
|
|
||||||
**Preset codes:** Version-prefixed base62 strings (e.g. `a2r6bw` or `b0`), 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
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
interface:
|
|
||||||
display_name: "shadcn/ui"
|
|
||||||
short_description: "Manages shadcn/ui components — adding, searching, fixing, debugging, styling, and composing UI."
|
|
||||||
icon_small: "./assets/shadcn-small.png"
|
|
||||||
icon_large: "./assets/shadcn.png"
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.8 KiB |
@@ -1,276 +0,0 @@
|
|||||||
# 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, apply, 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`.
|
|
||||||
|
|
||||||
### `apply` — Apply a preset to an existing project
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx shadcn@latest apply [preset] [options]
|
|
||||||
```
|
|
||||||
|
|
||||||
Applies a preset to an existing project, overwriting preset-driven config, fonts, CSS variables, and detected UI components.
|
|
||||||
|
|
||||||
| Flag | Short | Description | Default |
|
|
||||||
| ------------------- | ----- | ------------------------------------------ | ------- |
|
|
||||||
| `--preset <preset>` | — | Preset configuration (named, code, or URL) | — |
|
|
||||||
| `--yes` | `-y` | Skip confirmation prompt | `false` |
|
|
||||||
| `--cwd <cwd>` | `-c` | Working directory | current |
|
|
||||||
| `--silent` | `-s` | Mute output | `false` |
|
|
||||||
|
|
||||||
`[preset]` is a shorthand for `--preset <preset>`. If both are provided, they must match.
|
|
||||||
If no preset is provided, the CLI offers to open the custom preset builder on `ui.shadcn.com/create`.
|
|
||||||
|
|
||||||
### `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 nova` or `--preset lyra`
|
|
||||||
2. **Code:** `--preset a2r6bw` (version-prefixed base62 string, e.g. `a2r6bw` or `b0`)
|
|
||||||
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.
|
|
||||||
> Use `npx shadcn@latest apply --preset <code>` when overwriting an existing project's preset.
|
|
||||||
|
|
||||||
## Switching Presets
|
|
||||||
|
|
||||||
Ask the user first: **overwrite**, **merge**, or **skip** existing components?
|
|
||||||
|
|
||||||
- **Overwrite / Re-install** → `npx shadcn@latest apply --preset <code>`. Overwrites all detected 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.
|
|
||||||
|
|
||||||
Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base <current-base>` explicitly — preset codes do not encode the base.
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
# 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 apply --preset a2r6bw
|
|
||||||
|
|
||||||
# Positional shorthand also works.
|
|
||||||
npx shadcn@latest apply a2r6bw
|
|
||||||
|
|
||||||
# Switch to a named preset and overwrite existing components.
|
|
||||||
npx shadcn@latest apply --preset nova
|
|
||||||
|
|
||||||
# Preserve existing components instead.
|
|
||||||
npx shadcn@latest init --preset nova --force --no-reinstall
|
|
||||||
|
|
||||||
# Use a custom theme URL.
|
|
||||||
npx shadcn@latest apply --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..."
|
|
||||||
```
|
|
||||||
|
|
||||||
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="mx-auto max-w-md">...</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.
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
{
|
|
||||||
"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-*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
# 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`
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
# 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>
|
|
||||||
```
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
# 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">` |
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
# 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`.
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
# 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} />
|
|
||||||
```
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
# 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]`.
|
|
||||||
@@ -13,17 +13,7 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"terminal.integrated.shell.linux": "/bin/bash"
|
"terminal.integrated.shell.linux": "/bin/bash"
|
||||||
},
|
},
|
||||||
"vscode": {
|
"extensions": ["rust-lang.rust-analyzer", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", "nefrob.vscode-just-syntax"]
|
||||||
"extensions": [
|
|
||||||
"streetsidesoftware.code-spell-checker",
|
|
||||||
"mhutchie.git-graph",
|
|
||||||
"esbenp.prettier-vscode",
|
|
||||||
"rust-lang.rust-analyzer",
|
|
||||||
"tauri-apps.tauri-vscode",
|
|
||||||
"austenc.tailwind-docs",
|
|
||||||
"bradlc.vscode-tailwindcss"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"postCreateCommand": "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && cd src-tauri && cargo fetch && cd -",
|
"postCreateCommand": "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && cd src-tauri && cargo fetch && cd -",
|
||||||
"forwardPorts": [5173, 5900, 6080],
|
"forwardPorts": [5173, 5900, 6080],
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -28,5 +28,3 @@ dist-ssr
|
|||||||
|
|
||||||
# target
|
# target
|
||||||
target/
|
target/
|
||||||
|
|
||||||
.react-router/
|
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
|
||||||
"style": "radix-luma",
|
|
||||||
"rsc": false,
|
|
||||||
"tsx": true,
|
|
||||||
"tailwind": {
|
|
||||||
"config": "",
|
|
||||||
"css": "src/App.css",
|
|
||||||
"baseColor": "zinc",
|
|
||||||
"cssVariables": true,
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"iconLibrary": "lucide",
|
|
||||||
"rtl": false,
|
|
||||||
"aliases": {
|
|
||||||
"components": "#components",
|
|
||||||
"utils": "#lib/utils",
|
|
||||||
"ui": "#components/ui",
|
|
||||||
"lib": "#lib",
|
|
||||||
"hooks": "#hooks"
|
|
||||||
},
|
|
||||||
"menuColor": "default",
|
|
||||||
"menuAccent": "subtle",
|
|
||||||
"registries": {}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ pub use sea_orm_migration::prelude::*;
|
|||||||
|
|
||||||
mod m20260526_062833_create_account_table;
|
mod m20260526_062833_create_account_table;
|
||||||
mod m20260526_110957_create_transcations_table;
|
mod m20260526_110957_create_transcations_table;
|
||||||
|
mod m20260528_110733_create_cache_table;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ impl MigratorTrait for Migrator {
|
|||||||
vec![
|
vec![
|
||||||
Box::new(m20260526_062833_create_account_table::Migration),
|
Box::new(m20260526_062833_create_account_table::Migration),
|
||||||
Box::new(m20260526_110957_create_transcations_table::Migration),
|
Box::new(m20260526_110957_create_transcations_table::Migration),
|
||||||
|
Box::new(m20260528_110733_create_cache_table::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
61
crates/migration/src/m20260528_110733_create_cache_table.rs
Normal file
61
crates/migration/src/m20260528_110733_create_cache_table.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
use sea_orm_migration::{prelude::*, schema::*};
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table("cache")
|
||||||
|
.comment("2 level cache for general use")
|
||||||
|
.if_not_exists()
|
||||||
|
.col(string("category").not_null().comment("Cache category"))
|
||||||
|
.col(string("key").not_null().comment("Cache key"))
|
||||||
|
.col(string("value").not_null().comment("Cache value"))
|
||||||
|
.col(
|
||||||
|
boolean("is_invalidated")
|
||||||
|
.not_null()
|
||||||
|
.comment("Whether the cache is manually invalidated"),
|
||||||
|
)
|
||||||
|
.col(string("expire_at").null().comment("Expiration time"))
|
||||||
|
.col(string("created_at").not_null().comment("Creation time"))
|
||||||
|
.col(string("updated_at").not_null().comment("Last update time"))
|
||||||
|
//
|
||||||
|
.primary_key(Index::create().col("category").col("key"))
|
||||||
|
//
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_cache_category")
|
||||||
|
.table("cache")
|
||||||
|
.col("category")
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_cache_expire_at")
|
||||||
|
.table("cache")
|
||||||
|
.col("expire_at")
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table("cache").to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
37
package.json
37
package.json
@@ -4,46 +4,23 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "react-router build",
|
"dev": "vite",
|
||||||
"dev": "react-router dev",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "react-router typegen && tsc",
|
|
||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"imports": {
|
|
||||||
"#components/*": "./src/components/*.tsx",
|
|
||||||
"#lib/*": "./src/lib/*.ts",
|
|
||||||
"#hooks/*": "./src/hooks/*.ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
|
||||||
"@react-router/node": "^7.15.1",
|
|
||||||
"@react-router/serve": "^7.15.1",
|
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
|
||||||
"@tauri-apps/api": "^2",
|
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"isbot": "^5",
|
|
||||||
"lucide-react": "^1.16.0",
|
|
||||||
"radix-ui": "^1.4.3",
|
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router": "^7.15.1",
|
"@tauri-apps/api": "^2",
|
||||||
"shadcn": "^4.8.1",
|
"@tauri-apps/plugin-opener": "^2"
|
||||||
"tailwind-merge": "^3.6.0",
|
|
||||||
"tailwindcss": "^4.3.0",
|
|
||||||
"tw-animate-css": "^1.4.0",
|
|
||||||
"vaul": "^1.1.2",
|
|
||||||
"zustand": "^5.0.13"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-router/dev": "^7.15.1",
|
|
||||||
"@tauri-apps/cli": "^2",
|
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.0.4",
|
||||||
|
"@tauri-apps/cli": "^2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5397
pnpm-lock.yaml
generated
5397
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
esbuild: true
|
esbuild: true
|
||||||
msw: true
|
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- esbuild
|
- esbuild
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import type { Config } from '@react-router/dev/config';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
// Config options...
|
|
||||||
// Server-side render by default, to enable SPA mode set this to `false`
|
|
||||||
ssr: false,
|
|
||||||
appDirectory: 'src',
|
|
||||||
} satisfies Config;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"skills": {
|
|
||||||
"shadcn": {
|
|
||||||
"source": "shadcn/ui",
|
|
||||||
"sourceType": "github",
|
|
||||||
"skillPath": "skills/shadcn/SKILL.md",
|
|
||||||
"computedHash": "80a6226e78f6d1fe464214ae0ef449d49d8ffaa3e7704f011e9b418c678ad4d1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
24
src-tauri/src/db/entities/cache.rs
Normal file
24
src-tauri/src/db/entities/cache.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "cache")]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub category: String,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub key: String,
|
||||||
|
pub value: String,
|
||||||
|
pub is_invalidated: bool,
|
||||||
|
pub expire_at: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
@@ -4,6 +4,7 @@ pub mod prelude;
|
|||||||
|
|
||||||
pub mod account;
|
pub mod account;
|
||||||
pub mod account_category;
|
pub mod account_category;
|
||||||
|
pub mod cache;
|
||||||
pub mod category;
|
pub mod category;
|
||||||
pub mod tag;
|
pub mod tag;
|
||||||
pub mod transaction;
|
pub mod transaction;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
pub use super::account::Entity as Account;
|
pub use super::account::Entity as Account;
|
||||||
pub use super::account_category::Entity as AccountCategory;
|
pub use super::account_category::Entity as AccountCategory;
|
||||||
|
pub use super::cache::Entity as Cache;
|
||||||
pub use super::category::Entity as Category;
|
pub use super::category::Entity as Category;
|
||||||
pub use super::tag::Entity as Tag;
|
pub use super::tag::Entity as Tag;
|
||||||
pub use super::transaction::Entity as Transaction;
|
pub use super::transaction::Entity as Transaction;
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
// TODO: validation for all dto
|
|
||||||
use crate::db::{
|
|
||||||
Db,
|
|
||||||
entities::account_category::{
|
|
||||||
ActiveModel as AccountCategoryActiveModel, Entity as AccountCategoryEntity,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use migration::OnConflict;
|
|
||||||
use sea_orm::{
|
|
||||||
ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait,
|
|
||||||
IntoActiveModel, ModelTrait, PaginatorTrait, QueryFilter,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
pub struct AccountCategory {
|
|
||||||
pub id: i64,
|
|
||||||
pub name: String,
|
|
||||||
pub account_type: AccountType,
|
|
||||||
//
|
|
||||||
pub created_at: String,
|
|
||||||
pub updated_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum AccountType {
|
|
||||||
#[serde(rename = "Asset")]
|
|
||||||
Asset, // Positive balance, e.g. Checking, Savings
|
|
||||||
#[serde(rename = "Liability")]
|
|
||||||
Liability, // Negative balance, e.g. Credit Card
|
|
||||||
#[serde(other, rename = "Unknown")]
|
|
||||||
Unknown, // Fallback for unknown types
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct CreateAccountCategoryRequest {
|
|
||||||
pub name: String,
|
|
||||||
pub account_type: AccountType,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct UpdateAccountCategoryRequest {
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub account_type: Option<AccountType>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<String> for AccountType {
|
|
||||||
fn from(s: String) -> Self {
|
|
||||||
match s.as_str() {
|
|
||||||
"Asset" => AccountType::Asset,
|
|
||||||
"Liability" => AccountType::Liability,
|
|
||||||
_ => AccountType::Unknown,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for AccountType {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
AccountType::Asset => write!(f, "Asset"),
|
|
||||||
AccountType::Liability => write!(f, "Liability"),
|
|
||||||
AccountType::Unknown => write!(f, "Unknown"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
pub trait AccountCategoryService: Send + Sync + 'static {
|
|
||||||
async fn get_categories(&self) -> Result<Vec<AccountCategory>, String>;
|
|
||||||
async fn create_category(
|
|
||||||
&self,
|
|
||||||
create_request: CreateAccountCategoryRequest,
|
|
||||||
) -> Result<i64, String>;
|
|
||||||
|
|
||||||
async fn create_category_with_tx(
|
|
||||||
&self,
|
|
||||||
create_request: CreateAccountCategoryRequest,
|
|
||||||
tx: &Db,
|
|
||||||
) -> Result<i64, String>;
|
|
||||||
|
|
||||||
async fn update_category(
|
|
||||||
&self,
|
|
||||||
id: &i64,
|
|
||||||
update_request: UpdateAccountCategoryRequest,
|
|
||||||
) -> Result<(), String>;
|
|
||||||
|
|
||||||
async fn update_category_with_tx(
|
|
||||||
&self,
|
|
||||||
id: &i64,
|
|
||||||
update_request: UpdateAccountCategoryRequest,
|
|
||||||
tx: &Db,
|
|
||||||
) -> Result<(), String>;
|
|
||||||
|
|
||||||
async fn delete_category(&self, id: &i64) -> Result<(), String>;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AccountCategoryServiceImpl {
|
|
||||||
db: DatabaseConnection,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AccountCategoryServiceImpl {
|
|
||||||
pub fn new(db: DatabaseConnection) -> Self {
|
|
||||||
Self { db }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl AccountCategoryService for AccountCategoryServiceImpl {
|
|
||||||
async fn get_categories(&self) -> Result<Vec<AccountCategory>, String> {
|
|
||||||
let categories = AccountCategoryEntity::find()
|
|
||||||
.all(&self.db)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
Ok(categories
|
|
||||||
.into_iter()
|
|
||||||
.map(|model| AccountCategory {
|
|
||||||
id: model.id,
|
|
||||||
name: model.name,
|
|
||||||
account_type: AccountType::from(model.account_type),
|
|
||||||
created_at: model.created_at,
|
|
||||||
updated_at: model.updated_at,
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_category(
|
|
||||||
&self,
|
|
||||||
create_request: CreateAccountCategoryRequest,
|
|
||||||
) -> Result<i64, String> {
|
|
||||||
self.create_category_with_tx(create_request, &(&self.db).into())
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_category_with_tx(
|
|
||||||
&self,
|
|
||||||
create_request: CreateAccountCategoryRequest,
|
|
||||||
tx: &Db,
|
|
||||||
) -> Result<i64, String> {
|
|
||||||
let new_category = AccountCategoryActiveModel {
|
|
||||||
name: Set(create_request.name),
|
|
||||||
account_type: Set(create_request.account_type.to_string()),
|
|
||||||
created_at: Set(chrono::Utc::now().to_rfc3339()),
|
|
||||||
updated_at: Set(chrono::Utc::now().to_rfc3339()),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let res = AccountCategoryEntity::insert(new_category)
|
|
||||||
.on_conflict(
|
|
||||||
// force update to ensure a record is returned
|
|
||||||
OnConflict::column(crate::db::entities::account_category::Column::Name)
|
|
||||||
.update_column(crate::db::entities::account_category::Column::Name)
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.exec_with_returning(tx)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
Ok(res.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_category(&self, id: &i64) -> Result<(), String> {
|
|
||||||
// check if any accounts are using this category before deleting
|
|
||||||
let accounts_using_category = crate::db::entities::account::Entity::find()
|
|
||||||
.filter(crate::db::entities::account::Column::AccountCategoryId.eq(*id))
|
|
||||||
.count(&self.db)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
if accounts_using_category > 0 {
|
|
||||||
return Err("Cannot delete category that is in use by accounts".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let category = AccountCategoryEntity::find_by_id(*id)
|
|
||||||
.one(&self.db)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
if let Some(category) = category {
|
|
||||||
category.delete(&self.db).await.map_err(|e| e.to_string())?;
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err("Category not found".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_category(
|
|
||||||
&self,
|
|
||||||
id: &i64,
|
|
||||||
update_request: UpdateAccountCategoryRequest,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
self.update_category_with_tx(id, update_request, &(&self.db).into())
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_category_with_tx(
|
|
||||||
&self,
|
|
||||||
id: &i64,
|
|
||||||
update_request: UpdateAccountCategoryRequest,
|
|
||||||
tx: &Db,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let category = AccountCategoryEntity::find_by_id(*id)
|
|
||||||
.one(&self.db)
|
|
||||||
.await
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
.map(|opt| opt.map(|model| model.into_active_model()))?;
|
|
||||||
|
|
||||||
if let Some(mut category) = category {
|
|
||||||
if let Some(name) = update_request.name {
|
|
||||||
category.name = Set(name);
|
|
||||||
}
|
|
||||||
if let Some(account_type) = update_request.account_type {
|
|
||||||
category.account_type = Set(account_type.to_string());
|
|
||||||
}
|
|
||||||
category.updated_at = Set(chrono::Utc::now().to_rfc3339());
|
|
||||||
category.update(tx).await.map_err(|e| e.to_string())?;
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err("Category not found".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
141
src-tauri/src/services/accounts/category/dto.rs
Normal file
141
src-tauri/src/services/accounts/category/dto.rs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
use sea_orm::ActiveValue::Set;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::db::{
|
||||||
|
DbServiceError,
|
||||||
|
entities::account_category::{
|
||||||
|
ActiveModel as AccountCategoryActiveModel, Model as AccountCategoryModel,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum AccountCategoryServiceError {
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
DbErr(#[from] DbServiceError),
|
||||||
|
#[error("Target not found: {0}")]
|
||||||
|
TargetNotFound(String),
|
||||||
|
#[error("Validation error: {0}")]
|
||||||
|
Validation(String),
|
||||||
|
#[error("unknown error: {0}")]
|
||||||
|
Unknown(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sea_orm::DbErr> for AccountCategoryServiceError {
|
||||||
|
fn from(err: sea_orm::DbErr) -> Self {
|
||||||
|
AccountCategoryServiceError::DbErr(DbServiceError::from(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AccountCategory {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub account_type: AccountType,
|
||||||
|
//
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AccountCategoryModel> for AccountCategory {
|
||||||
|
fn from(model: AccountCategoryModel) -> Self {
|
||||||
|
Self {
|
||||||
|
id: model.id,
|
||||||
|
name: model.name,
|
||||||
|
account_type: AccountType::from(model.account_type),
|
||||||
|
created_at: model.created_at,
|
||||||
|
updated_at: model.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum AccountType {
|
||||||
|
#[serde(rename = "Asset")]
|
||||||
|
Asset, // Positive balance, e.g. Checking, Savings
|
||||||
|
#[serde(rename = "Liability")]
|
||||||
|
Liability, // Negative balance, e.g. Credit Card
|
||||||
|
#[serde(other, rename = "Unknown")]
|
||||||
|
Unknown, // Fallback for unknown types
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CreateAccountCategoryRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub account_type: AccountType,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateAccountCategoryRequest {
|
||||||
|
pub fn validate(&self) -> Result<(), AccountCategoryServiceError> {
|
||||||
|
if self.name.trim().is_empty() {
|
||||||
|
return Err(AccountCategoryServiceError::Validation(
|
||||||
|
"Name cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let AccountType::Unknown = self.account_type {
|
||||||
|
return Err(AccountCategoryServiceError::Validation(
|
||||||
|
"Invalid account type".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CreateAccountCategoryRequest> for AccountCategoryActiveModel {
|
||||||
|
fn from(request: CreateAccountCategoryRequest) -> Self {
|
||||||
|
AccountCategoryActiveModel {
|
||||||
|
name: Set(request.name),
|
||||||
|
account_type: Set(request.account_type.to_string()),
|
||||||
|
created_at: Set(chrono::Utc::now().to_rfc3339()),
|
||||||
|
updated_at: Set(chrono::Utc::now().to_rfc3339()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateAccountCategoryRequest {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub account_type: Option<AccountType>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateAccountCategoryRequest {
|
||||||
|
pub fn validate(&self) -> Result<(), AccountCategoryServiceError> {
|
||||||
|
if let Some(name) = &self.name {
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err(AccountCategoryServiceError::Validation(
|
||||||
|
"Name cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_to(self, transaction: &mut AccountCategoryActiveModel) {
|
||||||
|
if let Some(name) = self.name {
|
||||||
|
transaction.name = Set(name);
|
||||||
|
}
|
||||||
|
if let Some(account_type) = self.account_type {
|
||||||
|
transaction.account_type = Set(account_type.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for AccountType {
|
||||||
|
fn from(s: String) -> Self {
|
||||||
|
match s.as_str() {
|
||||||
|
"Asset" => AccountType::Asset,
|
||||||
|
"Liability" => AccountType::Liability,
|
||||||
|
_ => AccountType::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for AccountType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
AccountType::Asset => write!(f, "Asset"),
|
||||||
|
AccountType::Liability => write!(f, "Liability"),
|
||||||
|
AccountType::Unknown => write!(f, "Unknown"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src-tauri/src/services/accounts/category/mod.rs
Normal file
6
src-tauri/src/services/accounts/category/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
mod dto;
|
||||||
|
mod repo;
|
||||||
|
mod service;
|
||||||
|
|
||||||
|
pub use dto::*;
|
||||||
|
pub use service::*;
|
||||||
151
src-tauri/src/services/accounts/category/repo.rs
Normal file
151
src-tauri/src/services/accounts/category/repo.rs
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
use crate::{
|
||||||
|
db::{
|
||||||
|
Db,
|
||||||
|
entities::account_category::{
|
||||||
|
ActiveModel as AccountCategoryActiveModel, Entity as AccountCategoryEntity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
services::accounts::category::dto::{
|
||||||
|
AccountCategory, AccountCategoryServiceError, CreateAccountCategoryRequest,
|
||||||
|
UpdateAccountCategoryRequest,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use migration::OnConflict;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait,
|
||||||
|
IntoActiveModel, ModelTrait, PaginatorTrait, QueryFilter,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait AccountCategoryRepoService: Send + Sync + 'static {
|
||||||
|
async fn get_categories(&self) -> Result<Vec<AccountCategory>, AccountCategoryServiceError>;
|
||||||
|
async fn create_category(
|
||||||
|
&self,
|
||||||
|
create_request: CreateAccountCategoryRequest,
|
||||||
|
) -> Result<i64, AccountCategoryServiceError>;
|
||||||
|
|
||||||
|
async fn create_category_with_tx(
|
||||||
|
&self,
|
||||||
|
create_request: CreateAccountCategoryRequest,
|
||||||
|
tx: &Db,
|
||||||
|
) -> Result<i64, AccountCategoryServiceError>;
|
||||||
|
|
||||||
|
async fn update_category(
|
||||||
|
&self,
|
||||||
|
id: &i64,
|
||||||
|
update_request: UpdateAccountCategoryRequest,
|
||||||
|
) -> Result<(), AccountCategoryServiceError>;
|
||||||
|
|
||||||
|
async fn update_category_with_tx(
|
||||||
|
&self,
|
||||||
|
id: &i64,
|
||||||
|
update_request: UpdateAccountCategoryRequest,
|
||||||
|
tx: &Db,
|
||||||
|
) -> Result<(), AccountCategoryServiceError>;
|
||||||
|
|
||||||
|
async fn delete_category(&self, id: &i64) -> Result<(), AccountCategoryServiceError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AccountCategoryRepoServiceImpl {
|
||||||
|
db: DatabaseConnection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountCategoryRepoServiceImpl {
|
||||||
|
pub fn new(db: DatabaseConnection) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl AccountCategoryRepoService for AccountCategoryRepoServiceImpl {
|
||||||
|
async fn get_categories(&self) -> Result<Vec<AccountCategory>, AccountCategoryServiceError> {
|
||||||
|
let categories = AccountCategoryEntity::find().all(&self.db).await?;
|
||||||
|
Ok(categories.into_iter().map(|model| model.into()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_category(
|
||||||
|
&self,
|
||||||
|
create_request: CreateAccountCategoryRequest,
|
||||||
|
) -> Result<i64, AccountCategoryServiceError> {
|
||||||
|
self.create_category_with_tx(create_request, &(&self.db).into())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_category_with_tx(
|
||||||
|
&self,
|
||||||
|
create_request: CreateAccountCategoryRequest,
|
||||||
|
tx: &Db,
|
||||||
|
) -> Result<i64, AccountCategoryServiceError> {
|
||||||
|
create_request.validate()?;
|
||||||
|
let new_category: AccountCategoryActiveModel = create_request.into();
|
||||||
|
|
||||||
|
let res = AccountCategoryEntity::insert(new_category)
|
||||||
|
.on_conflict(
|
||||||
|
// force update to ensure a record is returned
|
||||||
|
OnConflict::column(crate::db::entities::account_category::Column::Name)
|
||||||
|
.update_column(crate::db::entities::account_category::Column::Name)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.exec_with_returning(tx)
|
||||||
|
.await?;
|
||||||
|
Ok(res.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_category(&self, id: &i64) -> Result<(), AccountCategoryServiceError> {
|
||||||
|
// check if any accounts are using this category before deleting
|
||||||
|
let accounts_using_category = crate::db::entities::account::Entity::find()
|
||||||
|
.filter(crate::db::entities::account::Column::AccountCategoryId.eq(*id))
|
||||||
|
.count(&self.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if accounts_using_category > 0 {
|
||||||
|
return Err(AccountCategoryServiceError::Validation(
|
||||||
|
"Cannot delete category that has accounts".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let category = AccountCategoryEntity::find_by_id(*id).one(&self.db).await?;
|
||||||
|
if let Some(category) = category {
|
||||||
|
category.delete(&self.db).await?;
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AccountCategoryServiceError::TargetNotFound(
|
||||||
|
"Category not found".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_category(
|
||||||
|
&self,
|
||||||
|
id: &i64,
|
||||||
|
update_request: UpdateAccountCategoryRequest,
|
||||||
|
) -> Result<(), AccountCategoryServiceError> {
|
||||||
|
self.update_category_with_tx(id, update_request, &(&self.db).into())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_category_with_tx(
|
||||||
|
&self,
|
||||||
|
id: &i64,
|
||||||
|
update_request: UpdateAccountCategoryRequest,
|
||||||
|
tx: &Db,
|
||||||
|
) -> Result<(), AccountCategoryServiceError> {
|
||||||
|
update_request.validate()?;
|
||||||
|
|
||||||
|
let category = AccountCategoryEntity::find_by_id(*id)
|
||||||
|
.one(&self.db)
|
||||||
|
.await
|
||||||
|
.map(|opt| opt.map(|model| model.into_active_model()))?;
|
||||||
|
|
||||||
|
if let Some(mut category) = category {
|
||||||
|
update_request.apply_to(&mut category);
|
||||||
|
category.updated_at = Set(chrono::Utc::now().to_rfc3339());
|
||||||
|
category.update(tx).await?;
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AccountCategoryServiceError::TargetNotFound(
|
||||||
|
"Category not found".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src-tauri/src/services/accounts/category/service.rs
Normal file
107
src-tauri/src/services/accounts/category/service.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::Db,
|
||||||
|
services::accounts::category::{
|
||||||
|
dto::{
|
||||||
|
AccountCategory, AccountCategoryServiceError, CreateAccountCategoryRequest,
|
||||||
|
UpdateAccountCategoryRequest,
|
||||||
|
},
|
||||||
|
repo::AccountCategoryRepoService,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use sea_orm::DatabaseConnection;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait AccountCategoryService: Send + Sync + 'static {
|
||||||
|
async fn get_categories(&self) -> Result<Vec<AccountCategory>, AccountCategoryServiceError>;
|
||||||
|
async fn create_category(
|
||||||
|
&self,
|
||||||
|
create_request: CreateAccountCategoryRequest,
|
||||||
|
) -> Result<i64, AccountCategoryServiceError>;
|
||||||
|
|
||||||
|
async fn create_category_with_tx(
|
||||||
|
&self,
|
||||||
|
create_request: CreateAccountCategoryRequest,
|
||||||
|
tx: &Db,
|
||||||
|
) -> Result<i64, AccountCategoryServiceError>;
|
||||||
|
|
||||||
|
async fn update_category(
|
||||||
|
&self,
|
||||||
|
id: &i64,
|
||||||
|
update_request: UpdateAccountCategoryRequest,
|
||||||
|
) -> Result<(), AccountCategoryServiceError>;
|
||||||
|
|
||||||
|
async fn update_category_with_tx(
|
||||||
|
&self,
|
||||||
|
id: &i64,
|
||||||
|
update_request: UpdateAccountCategoryRequest,
|
||||||
|
tx: &Db,
|
||||||
|
) -> Result<(), AccountCategoryServiceError>;
|
||||||
|
|
||||||
|
async fn delete_category(&self, id: &i64) -> Result<(), AccountCategoryServiceError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AccountCategoryServiceImpl<T>
|
||||||
|
where
|
||||||
|
T: super::repo::AccountCategoryRepoService + ?Sized,
|
||||||
|
{
|
||||||
|
db: DatabaseConnection,
|
||||||
|
repo: Arc<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountCategoryServiceImpl<super::repo::AccountCategoryRepoServiceImpl> {
|
||||||
|
pub fn new(db: DatabaseConnection) -> Self {
|
||||||
|
Self {
|
||||||
|
db: db.clone(),
|
||||||
|
repo: Arc::new(super::repo::AccountCategoryRepoServiceImpl::new(db.clone())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl AccountCategoryService
|
||||||
|
for AccountCategoryServiceImpl<super::repo::AccountCategoryRepoServiceImpl>
|
||||||
|
{
|
||||||
|
async fn get_categories(&self) -> Result<Vec<AccountCategory>, AccountCategoryServiceError> {
|
||||||
|
self.repo.get_categories().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_category(
|
||||||
|
&self,
|
||||||
|
create_request: CreateAccountCategoryRequest,
|
||||||
|
) -> Result<i64, AccountCategoryServiceError> {
|
||||||
|
self.repo.create_category(create_request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_category_with_tx(
|
||||||
|
&self,
|
||||||
|
create_request: CreateAccountCategoryRequest,
|
||||||
|
tx: &Db,
|
||||||
|
) -> Result<i64, AccountCategoryServiceError> {
|
||||||
|
self.repo.create_category_with_tx(create_request, tx).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_category(&self, id: &i64) -> Result<(), AccountCategoryServiceError> {
|
||||||
|
self.repo.delete_category(id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_category(
|
||||||
|
&self,
|
||||||
|
id: &i64,
|
||||||
|
update_request: UpdateAccountCategoryRequest,
|
||||||
|
) -> Result<(), AccountCategoryServiceError> {
|
||||||
|
self.repo.update_category(id, update_request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_category_with_tx(
|
||||||
|
&self,
|
||||||
|
id: &i64,
|
||||||
|
update_request: UpdateAccountCategoryRequest,
|
||||||
|
tx: &Db,
|
||||||
|
) -> Result<(), AccountCategoryServiceError> {
|
||||||
|
self.repo
|
||||||
|
.update_category_with_tx(id, update_request, tx)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
130
src-tauri/src/services/accounts/dto.rs
Normal file
130
src-tauri/src/services/accounts/dto.rs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
use sea_orm::ActiveValue::Set;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::{
|
||||||
|
DbServiceError,
|
||||||
|
entities::{
|
||||||
|
account::{ActiveModel as AccountActiveModel, Model as AccountModel},
|
||||||
|
account_category::Model as AccountCategoryModel,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
services::accounts::category::{
|
||||||
|
AccountCategoryServiceError, AccountType, CreateAccountCategoryRequest,
|
||||||
|
UpdateAccountCategoryRequest,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum AccountsServiceError {
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
DbErr(#[from] DbServiceError),
|
||||||
|
#[error("Target not found: {0}")]
|
||||||
|
TargetNotFound(String),
|
||||||
|
#[error("Validation error: {0}")]
|
||||||
|
Validation(String),
|
||||||
|
#[error("unknown error: {0}")]
|
||||||
|
Unknown(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sea_orm::DbErr> for AccountsServiceError {
|
||||||
|
fn from(err: sea_orm::DbErr) -> Self {
|
||||||
|
AccountsServiceError::DbErr(DbServiceError::from(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AccountCategoryServiceError> for AccountsServiceError {
|
||||||
|
fn from(err: AccountCategoryServiceError) -> Self {
|
||||||
|
match err {
|
||||||
|
AccountCategoryServiceError::DbErr(db_err) => AccountsServiceError::DbErr(db_err),
|
||||||
|
AccountCategoryServiceError::Validation(msg) => AccountsServiceError::Validation(msg),
|
||||||
|
AccountCategoryServiceError::TargetNotFound(msg) => {
|
||||||
|
AccountsServiceError::TargetNotFound(msg)
|
||||||
|
}
|
||||||
|
AccountCategoryServiceError::Unknown(msg) => AccountsServiceError::Unknown(msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Account {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub account_type: AccountType,
|
||||||
|
pub account_category: String,
|
||||||
|
//
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<(AccountModel, AccountCategoryModel)> for Account {
|
||||||
|
fn from((account_model, category_model): (AccountModel, AccountCategoryModel)) -> Self {
|
||||||
|
Account {
|
||||||
|
id: account_model.id,
|
||||||
|
name: account_model.name,
|
||||||
|
account_type: AccountType::from(category_model.account_type),
|
||||||
|
account_category: category_model.name,
|
||||||
|
created_at: account_model.created_at,
|
||||||
|
updated_at: account_model.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CreateAccountRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub category: CreateAccountCategoryRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateAccountRequest {
|
||||||
|
pub fn validate(&self) -> Result<(), AccountsServiceError> {
|
||||||
|
if self.name.trim().is_empty() {
|
||||||
|
return Err(AccountsServiceError::Validation(
|
||||||
|
"Account name cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.category.validate()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<(CreateAccountRequest, i64)> for AccountActiveModel {
|
||||||
|
fn from((request, category_id): (CreateAccountRequest, i64)) -> Self {
|
||||||
|
AccountActiveModel {
|
||||||
|
name: Set(request.name),
|
||||||
|
account_category_id: Set(category_id),
|
||||||
|
created_at: Set(chrono::Utc::now().to_rfc3339()),
|
||||||
|
updated_at: Set(chrono::Utc::now().to_rfc3339()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateAccountRequest {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub category: Option<UpdateAccountCategoryRequest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateAccountRequest {
|
||||||
|
pub fn validate(&self) -> Result<(), AccountsServiceError> {
|
||||||
|
if let Some(name) = &self.name {
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err(AccountsServiceError::Validation(
|
||||||
|
"Account name cannot be empty".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(category) = &self.category {
|
||||||
|
category.validate()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_to(&self, account: &mut AccountActiveModel) {
|
||||||
|
if let Some(name) = &self.name {
|
||||||
|
account.name = Set(name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,191 +1,7 @@
|
|||||||
// TODO: validation for all dto
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
db::entities::{
|
|
||||||
account::{
|
|
||||||
ActiveModel as AccountActiveModel, Entity as AccountEntity, Model as AccountModel,
|
|
||||||
},
|
|
||||||
account_category::{
|
|
||||||
ActiveModel as AccountCategoryActiveModel, Entity as AccountCategoryEntity,
|
|
||||||
Model as AccountCategoryModel,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
services::accounts::category::{
|
|
||||||
AccountType, CreateAccountCategoryRequest, UpdateAccountCategoryRequest,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
use sea_orm::{
|
|
||||||
ActiveModelTrait, ActiveValue::Set, DatabaseConnection, EntityTrait, TransactionTrait,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
pub mod category;
|
pub mod category;
|
||||||
|
mod dto;
|
||||||
|
mod repo;
|
||||||
|
mod service;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
pub use dto::*;
|
||||||
pub struct Account {
|
pub use service::*;
|
||||||
pub id: i64,
|
|
||||||
pub name: String,
|
|
||||||
pub account_type: AccountType,
|
|
||||||
pub account_category: String,
|
|
||||||
//
|
|
||||||
pub created_at: String,
|
|
||||||
pub updated_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<(AccountModel, AccountCategoryModel)> for Account {
|
|
||||||
fn from((account_model, category_model): (AccountModel, AccountCategoryModel)) -> Self {
|
|
||||||
Account {
|
|
||||||
id: account_model.id,
|
|
||||||
name: account_model.name,
|
|
||||||
account_type: AccountType::from(category_model.account_type),
|
|
||||||
account_category: category_model.name,
|
|
||||||
created_at: account_model.created_at,
|
|
||||||
updated_at: account_model.updated_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct CreateAccountRequest {
|
|
||||||
pub name: String,
|
|
||||||
pub category: CreateAccountCategoryRequest,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct UpdateAccountRequest {
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub category: Option<UpdateAccountCategoryRequest>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
pub trait AccountsService: Send + Sync + 'static {
|
|
||||||
async fn get_accounts(&self) -> Result<Vec<Account>, String>;
|
|
||||||
async fn get_account(&self, id: &i64) -> Result<Option<Account>, String>;
|
|
||||||
async fn create_account(&self, create_request: CreateAccountRequest) -> Result<i64, String>;
|
|
||||||
async fn update_account(
|
|
||||||
&self,
|
|
||||||
id: &i64,
|
|
||||||
update_request: UpdateAccountRequest,
|
|
||||||
) -> Result<(), String>;
|
|
||||||
async fn delete_account(&self, id: &i64) -> Result<(), String>;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AccountsServiceImpl {
|
|
||||||
db: DatabaseConnection,
|
|
||||||
account_category_service: Arc<dyn category::AccountCategoryService>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AccountsServiceImpl {
|
|
||||||
pub fn new(
|
|
||||||
db: DatabaseConnection,
|
|
||||||
account_category_service: Arc<dyn category::AccountCategoryService>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
db,
|
|
||||||
account_category_service,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl AccountsService for AccountsServiceImpl {
|
|
||||||
async fn get_accounts(&self) -> Result<Vec<Account>, String> {
|
|
||||||
let accounts = AccountEntity::find()
|
|
||||||
.find_both_related(AccountCategoryEntity)
|
|
||||||
.all(&self.db)
|
|
||||||
.await;
|
|
||||||
match accounts {
|
|
||||||
Ok(accounts) => Ok(accounts.into_iter().map(|a| a.into()).collect()),
|
|
||||||
Err(e) => Err(format!("Failed to fetch accounts: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_account(&self, id: &i64) -> Result<Option<Account>, String> {
|
|
||||||
let result = AccountEntity::find_by_id(*id)
|
|
||||||
.find_both_related(AccountCategoryEntity)
|
|
||||||
.one(&self.db)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(Some(account)) => Ok(Some(account.into())),
|
|
||||||
Ok(None) => Ok(None),
|
|
||||||
Err(e) => Err(format!("Failed to fetch account: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_account(&self, create_request: CreateAccountRequest) -> Result<i64, String> {
|
|
||||||
let tx = self.db.begin().await.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
let category_id = self
|
|
||||||
.account_category_service
|
|
||||||
.create_category_with_tx(
|
|
||||||
CreateAccountCategoryRequest {
|
|
||||||
name: create_request.category.name,
|
|
||||||
account_type: create_request.category.account_type,
|
|
||||||
},
|
|
||||||
&(&tx).into(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let new_account = AccountEntity::insert(AccountActiveModel {
|
|
||||||
name: Set(create_request.name),
|
|
||||||
created_at: Set(chrono::Utc::now().to_rfc3339()),
|
|
||||||
updated_at: Set(chrono::Utc::now().to_rfc3339()),
|
|
||||||
account_category_id: Set(category_id),
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.exec(&tx)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
tx.commit().await.map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
match new_account {
|
|
||||||
Ok(res) => Ok(res.last_insert_id),
|
|
||||||
Err(e) => Err(format!("Failed to create account: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_account(
|
|
||||||
&self,
|
|
||||||
id: &i64,
|
|
||||||
update_request: UpdateAccountRequest,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let tx = self.db.begin().await.map_err(|e| e.to_string())?;
|
|
||||||
let account = AccountEntity::find_by_id(*id).one(&tx).await;
|
|
||||||
match account {
|
|
||||||
Ok(Some(account)) => {
|
|
||||||
let mut active_model: AccountActiveModel = account.into();
|
|
||||||
// Only update fields that are provided in the request
|
|
||||||
if let Some(name) = update_request.name {
|
|
||||||
active_model.name = Set(name);
|
|
||||||
}
|
|
||||||
//
|
|
||||||
if let Some(category_request) = update_request.category {
|
|
||||||
// update the category if it is provided in the request
|
|
||||||
self.account_category_service
|
|
||||||
.update_category_with_tx(id, category_request, &(&tx).into())
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
active_model.updated_at = Set(chrono::Utc::now().to_rfc3339());
|
|
||||||
//
|
|
||||||
active_model.update(&tx).await.map_err(|e| e.to_string())?;
|
|
||||||
let res = tx.commit().await.map_err(|e| e.to_string());
|
|
||||||
match res {
|
|
||||||
Ok(_) => Ok(()),
|
|
||||||
Err(e) => Err(format!("Failed to update account: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None) => Err("Account not found".to_string()),
|
|
||||||
Err(e) => Err(format!("Failed to fetch account: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_account(&self, id: &i64) -> Result<(), String> {
|
|
||||||
let res = AccountEntity::delete_by_id(*id).exec(&self.db).await;
|
|
||||||
match res {
|
|
||||||
Ok(_) => Ok(()),
|
|
||||||
Err(e) => Err(format!("Failed to delete account: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
150
src-tauri/src/services/accounts/repo.rs
Normal file
150
src-tauri/src/services/accounts/repo.rs
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ActiveValue::Set, DatabaseConnection, EntityTrait, TransactionTrait,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::entities::{
|
||||||
|
account::{ActiveModel as AccountActiveModel, Entity as AccountEntity},
|
||||||
|
account_category::Entity as AccountCategoryEntity,
|
||||||
|
},
|
||||||
|
services::accounts::{
|
||||||
|
category::CreateAccountCategoryRequest,
|
||||||
|
dto::{Account, AccountsServiceError, CreateAccountRequest, UpdateAccountRequest},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait AccountsRepoService: Send + Sync + 'static {
|
||||||
|
async fn get_accounts(&self) -> Result<Vec<Account>, AccountsServiceError>;
|
||||||
|
async fn get_account(&self, id: &i64) -> Result<Option<Account>, AccountsServiceError>;
|
||||||
|
async fn create_account(
|
||||||
|
&self,
|
||||||
|
create_request: CreateAccountRequest,
|
||||||
|
) -> Result<i64, AccountsServiceError>;
|
||||||
|
async fn update_account(
|
||||||
|
&self,
|
||||||
|
id: &i64,
|
||||||
|
update_request: UpdateAccountRequest,
|
||||||
|
) -> Result<(), AccountsServiceError>;
|
||||||
|
async fn delete_account(&self, id: &i64) -> Result<(), AccountsServiceError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AccountsRepoServiceImpl<T>
|
||||||
|
where
|
||||||
|
T: super::category::AccountCategoryService + ?Sized,
|
||||||
|
{
|
||||||
|
db: DatabaseConnection,
|
||||||
|
account_category_service: Arc<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> AccountsRepoServiceImpl<T>
|
||||||
|
where
|
||||||
|
T: super::category::AccountCategoryService + ?Sized,
|
||||||
|
{
|
||||||
|
pub fn new(db: DatabaseConnection, account_category_service: Arc<T>) -> Self {
|
||||||
|
Self {
|
||||||
|
db,
|
||||||
|
account_category_service,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl<T> AccountsRepoService for AccountsRepoServiceImpl<T>
|
||||||
|
where
|
||||||
|
T: super::category::AccountCategoryService + ?Sized,
|
||||||
|
{
|
||||||
|
async fn get_accounts(&self) -> Result<Vec<Account>, AccountsServiceError> {
|
||||||
|
let accounts = AccountEntity::find()
|
||||||
|
.find_both_related(AccountCategoryEntity)
|
||||||
|
.all(&self.db)
|
||||||
|
.await?;
|
||||||
|
Ok(accounts.into_iter().map(|a| a.into()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_account(&self, id: &i64) -> Result<Option<Account>, AccountsServiceError> {
|
||||||
|
let result = AccountEntity::find_by_id(*id)
|
||||||
|
.find_both_related(AccountCategoryEntity)
|
||||||
|
.one(&self.db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(Some(account)) => Ok(Some(account.into())),
|
||||||
|
Ok(None) => Ok(None),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_account(
|
||||||
|
&self,
|
||||||
|
create_request: CreateAccountRequest,
|
||||||
|
) -> Result<i64, AccountsServiceError> {
|
||||||
|
let tx = self.db.begin().await?;
|
||||||
|
|
||||||
|
let category_id = self
|
||||||
|
.account_category_service
|
||||||
|
.create_category_with_tx(
|
||||||
|
CreateAccountCategoryRequest {
|
||||||
|
name: create_request.category.name,
|
||||||
|
account_type: create_request.category.account_type,
|
||||||
|
},
|
||||||
|
&(&tx).into(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let new_account = AccountEntity::insert(AccountActiveModel {
|
||||||
|
name: Set(create_request.name),
|
||||||
|
created_at: Set(chrono::Utc::now().to_rfc3339()),
|
||||||
|
updated_at: Set(chrono::Utc::now().to_rfc3339()),
|
||||||
|
account_category_id: Set(category_id),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.exec(&tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(new_account.last_insert_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_account(
|
||||||
|
&self,
|
||||||
|
id: &i64,
|
||||||
|
update_request: UpdateAccountRequest,
|
||||||
|
) -> Result<(), AccountsServiceError> {
|
||||||
|
let tx = self.db.begin().await?;
|
||||||
|
let account = AccountEntity::find_by_id(*id).one(&tx).await;
|
||||||
|
match account {
|
||||||
|
Ok(Some(account)) => {
|
||||||
|
let mut active_model: AccountActiveModel = account.into();
|
||||||
|
// Only update fields that are provided in the request
|
||||||
|
if let Some(name) = update_request.name {
|
||||||
|
active_model.name = Set(name);
|
||||||
|
}
|
||||||
|
//
|
||||||
|
if let Some(category_request) = update_request.category {
|
||||||
|
// update the category if it is provided in the request
|
||||||
|
self.account_category_service
|
||||||
|
.update_category_with_tx(id, category_request, &(&tx).into())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
active_model.updated_at = Set(chrono::Utc::now().to_rfc3339());
|
||||||
|
//
|
||||||
|
active_model.update(&tx).await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Ok(None) => Err(AccountsServiceError::TargetNotFound(
|
||||||
|
"Account not found".to_string(),
|
||||||
|
)),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_account(&self, id: &i64) -> Result<(), AccountsServiceError> {
|
||||||
|
AccountEntity::delete_by_id(*id).exec(&self.db).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src-tauri/src/services/accounts/service.rs
Normal file
78
src-tauri/src/services/accounts/service.rs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::services::accounts::{
|
||||||
|
Account, AccountsServiceError, CreateAccountRequest, UpdateAccountRequest,
|
||||||
|
};
|
||||||
|
use sea_orm::DatabaseConnection;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait AccountsService: Send + Sync + 'static {
|
||||||
|
async fn get_accounts(&self) -> Result<Vec<Account>, AccountsServiceError>;
|
||||||
|
async fn get_account(&self, id: &i64) -> Result<Option<Account>, AccountsServiceError>;
|
||||||
|
async fn create_account(
|
||||||
|
&self,
|
||||||
|
create_request: CreateAccountRequest,
|
||||||
|
) -> Result<i64, AccountsServiceError>;
|
||||||
|
async fn update_account(
|
||||||
|
&self,
|
||||||
|
id: &i64,
|
||||||
|
update_request: UpdateAccountRequest,
|
||||||
|
) -> Result<(), AccountsServiceError>;
|
||||||
|
async fn delete_account(&self, id: &i64) -> Result<(), AccountsServiceError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AccountsServiceImpl<T>
|
||||||
|
where
|
||||||
|
T: super::repo::AccountsRepoService + ?Sized,
|
||||||
|
{
|
||||||
|
db: DatabaseConnection,
|
||||||
|
repo: Arc<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> AccountsServiceImpl<super::repo::AccountsRepoServiceImpl<T>>
|
||||||
|
where
|
||||||
|
T: super::category::AccountCategoryService + ?Sized,
|
||||||
|
{
|
||||||
|
pub fn new(db: DatabaseConnection, account_category_service: Arc<T>) -> Self {
|
||||||
|
Self {
|
||||||
|
db: db.clone(),
|
||||||
|
repo: Arc::new(super::repo::AccountsRepoServiceImpl::new(
|
||||||
|
db.clone(),
|
||||||
|
account_category_service,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl<T> AccountsService for AccountsServiceImpl<T>
|
||||||
|
where
|
||||||
|
T: super::repo::AccountsRepoService + ?Sized,
|
||||||
|
{
|
||||||
|
async fn get_accounts(&self) -> Result<Vec<Account>, AccountsServiceError> {
|
||||||
|
self.repo.get_accounts().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_account(&self, id: &i64) -> Result<Option<Account>, AccountsServiceError> {
|
||||||
|
self.repo.get_account(id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_account(
|
||||||
|
&self,
|
||||||
|
create_request: CreateAccountRequest,
|
||||||
|
) -> Result<i64, AccountsServiceError> {
|
||||||
|
self.repo.create_account(create_request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_account(
|
||||||
|
&self,
|
||||||
|
id: &i64,
|
||||||
|
update_request: UpdateAccountRequest,
|
||||||
|
) -> Result<(), AccountsServiceError> {
|
||||||
|
self.repo.update_account(id, update_request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_account(&self, id: &i64) -> Result<(), AccountsServiceError> {
|
||||||
|
self.repo.delete_account(id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src-tauri/src/services/cache/dto.rs
vendored
Normal file
108
src-tauri/src/services/cache/dto.rs
vendored
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
use std::fmt::Display;
|
||||||
|
|
||||||
|
use crate::db::entities::cache::Model as CacheModel;
|
||||||
|
|
||||||
|
use sea_orm::DbErr;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::db::DbServiceError;
|
||||||
|
|
||||||
|
pub enum CacheValue {
|
||||||
|
Ok(String),
|
||||||
|
Expired(String),
|
||||||
|
NotFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Option<CacheModel>> for CacheValue {
|
||||||
|
fn from(opt: Option<CacheModel>) -> Self {
|
||||||
|
match opt {
|
||||||
|
Some(model) => model.into(),
|
||||||
|
None => CacheValue::NotFound,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CacheModel> for (String, CacheValue) {
|
||||||
|
fn from(model: CacheModel) -> Self {
|
||||||
|
let key = model.key.clone();
|
||||||
|
let value = model.into();
|
||||||
|
(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CacheModel> for CacheValue {
|
||||||
|
fn from(model: CacheModel) -> Self {
|
||||||
|
if let Some(expire_at) = model.expire_at {
|
||||||
|
let expire_at = chrono::DateTime::parse_from_rfc3339(&expire_at)
|
||||||
|
.map(|dt| dt.with_timezone(&chrono::Utc));
|
||||||
|
|
||||||
|
match expire_at {
|
||||||
|
Ok(expire_at) if expire_at < chrono::Utc::now() => {
|
||||||
|
return CacheValue::Expired(model.value);
|
||||||
|
}
|
||||||
|
Ok(_) => {} // Not expired
|
||||||
|
Err(_) => {
|
||||||
|
// warn!("Failed to parse expire_at: {}", model.expire_at);
|
||||||
|
// If parsing fails, treat it as expired to be safe
|
||||||
|
return CacheValue::Expired(model.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CacheValue::Ok(model.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum CacheServiceError {
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
DbErr(#[from] DbServiceError),
|
||||||
|
#[error("target expired: {0}")]
|
||||||
|
TargetExpired(String),
|
||||||
|
#[error("Target not found: {0}")]
|
||||||
|
TargetNotFound(String),
|
||||||
|
#[error("Validation error: {0}")]
|
||||||
|
Validation(String),
|
||||||
|
#[error("unknown error: {0}")]
|
||||||
|
Unknown(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DbErr> for CacheServiceError {
|
||||||
|
fn from(value: DbErr) -> Self {
|
||||||
|
CacheServiceError::DbErr(DbServiceError::from(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum CacheCategory {
|
||||||
|
Transaction,
|
||||||
|
TransactionTag,
|
||||||
|
Account,
|
||||||
|
Unknown(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for CacheCategory {
|
||||||
|
fn from(value: &str) -> Self {
|
||||||
|
match value {
|
||||||
|
"Transaction" => CacheCategory::Transaction,
|
||||||
|
"TransactionTag" => CacheCategory::TransactionTag,
|
||||||
|
"Account" => CacheCategory::Account,
|
||||||
|
other => {
|
||||||
|
// warn!("Unknown cache category: {}", other);
|
||||||
|
CacheCategory::Unknown(other.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for CacheCategory {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let s = match self {
|
||||||
|
CacheCategory::Transaction => "Transaction",
|
||||||
|
CacheCategory::TransactionTag => "TransactionTag",
|
||||||
|
CacheCategory::Account => "Account",
|
||||||
|
CacheCategory::Unknown(other) => other.as_str(),
|
||||||
|
};
|
||||||
|
write!(f, "{}", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src-tauri/src/services/cache/mod.rs
vendored
Normal file
6
src-tauri/src/services/cache/mod.rs
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
mod dto;
|
||||||
|
mod repo;
|
||||||
|
mod service;
|
||||||
|
|
||||||
|
pub use dto::*;
|
||||||
|
pub use service::*;
|
||||||
182
src-tauri/src/services/cache/repo.rs
vendored
Normal file
182
src-tauri/src/services/cache/repo.rs
vendored
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use migration::Expr;
|
||||||
|
use sea_orm::{ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Value};
|
||||||
|
|
||||||
|
use super::{dto::CacheCategory, service::CacheResult};
|
||||||
|
use crate::db::{
|
||||||
|
Db,
|
||||||
|
entities::cache::{
|
||||||
|
ActiveModel as CacheActiveModel, Column as CacheColumn, Entity as CacheEntity,
|
||||||
|
Model as CacheModel,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type Result<T> = CacheResult<T>;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait CacheRepo: Send + Sync + 'static {
|
||||||
|
async fn get_category(&self, category: CacheCategory) -> Result<HashMap<String, CacheModel>>;
|
||||||
|
async fn get(&self, category: CacheCategory, key: &str) -> Result<Option<CacheModel>>;
|
||||||
|
async fn set_with_tx(
|
||||||
|
&self,
|
||||||
|
tx: &Db,
|
||||||
|
category: CacheCategory,
|
||||||
|
key: &str,
|
||||||
|
value: &str,
|
||||||
|
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn set(
|
||||||
|
&self,
|
||||||
|
category: CacheCategory,
|
||||||
|
key: &str,
|
||||||
|
value: &str,
|
||||||
|
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn invalidate_category(&self, category: CacheCategory) -> Result<()>;
|
||||||
|
async fn invalidate_with_tx(&self, tx: &Db, category: CacheCategory, key: &str) -> Result<()>;
|
||||||
|
async fn invalidate(&self, category: CacheCategory, key: &str) -> Result<()>;
|
||||||
|
async fn set_expire(
|
||||||
|
&self,
|
||||||
|
category: CacheCategory,
|
||||||
|
key: &str,
|
||||||
|
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
) -> Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CacheRepoImpl {
|
||||||
|
db: DatabaseConnection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CacheRepoImpl {
|
||||||
|
pub fn new(db: DatabaseConnection) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl CacheRepo for CacheRepoImpl {
|
||||||
|
async fn get_category(&self, category: CacheCategory) -> Result<HashMap<String, CacheModel>> {
|
||||||
|
let cache_entries = CacheEntity::find()
|
||||||
|
.filter(CacheColumn::Category.eq(category.to_string()))
|
||||||
|
.all(&self.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let result = cache_entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|entry| (entry.key.clone(), entry))
|
||||||
|
.collect();
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, category: CacheCategory, key: &str) -> Result<Option<CacheModel>> {
|
||||||
|
let cache_entry = CacheEntity::find()
|
||||||
|
.filter(CacheColumn::Category.eq(category.to_string()))
|
||||||
|
.filter(CacheColumn::Key.eq(key.to_string()))
|
||||||
|
.one(&self.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(cache_entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set(
|
||||||
|
&self,
|
||||||
|
category: CacheCategory,
|
||||||
|
key: &str,
|
||||||
|
value: &str,
|
||||||
|
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.set_with_tx(&(&self.db).into(), category, key, value, expire_at)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_with_tx(
|
||||||
|
&self,
|
||||||
|
tx: &Db,
|
||||||
|
category: CacheCategory,
|
||||||
|
key: &str,
|
||||||
|
value: &str,
|
||||||
|
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let expire_at_str = expire_at.map(|dt| dt.to_rfc3339());
|
||||||
|
let now_str = chrono::Utc::now().to_rfc3339();
|
||||||
|
let active_model = CacheActiveModel {
|
||||||
|
category: Set(category.to_string()),
|
||||||
|
key: Set(key.to_string()),
|
||||||
|
value: Set(value.to_string()),
|
||||||
|
is_invalidated: Set(false),
|
||||||
|
expire_at: Set(expire_at_str),
|
||||||
|
created_at: Set(now_str.clone()),
|
||||||
|
updated_at: Set(now_str),
|
||||||
|
};
|
||||||
|
|
||||||
|
CacheEntity::insert(active_model)
|
||||||
|
.on_conflict(
|
||||||
|
sea_orm::sea_query::OnConflict::columns([CacheColumn::Category, CacheColumn::Key])
|
||||||
|
.update_columns([
|
||||||
|
CacheColumn::Value,
|
||||||
|
CacheColumn::IsInvalidated,
|
||||||
|
CacheColumn::ExpireAt,
|
||||||
|
CacheColumn::UpdatedAt,
|
||||||
|
])
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.exec(tx)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invalidate_category(&self, category: CacheCategory) -> Result<()> {
|
||||||
|
CacheEntity::update_many()
|
||||||
|
.col_expr(
|
||||||
|
CacheColumn::IsInvalidated,
|
||||||
|
Expr::value(Value::Bool(Some(true))),
|
||||||
|
)
|
||||||
|
.filter(CacheColumn::Category.eq(category.to_string()))
|
||||||
|
.exec(&self.db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invalidate(&self, category: CacheCategory, key: &str) -> Result<()> {
|
||||||
|
self.invalidate_with_tx(&(&self.db).into(), category, key)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invalidate_with_tx(&self, tx: &Db, category: CacheCategory, key: &str) -> Result<()> {
|
||||||
|
let target_entry = CacheActiveModel {
|
||||||
|
// pks
|
||||||
|
category: Set(category.to_string()),
|
||||||
|
key: Set(key.to_string()),
|
||||||
|
// update
|
||||||
|
is_invalidated: Set(true),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
CacheEntity::update(target_entry)
|
||||||
|
.validate()?
|
||||||
|
.exec(tx)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn set_expire(
|
||||||
|
&self,
|
||||||
|
category: CacheCategory,
|
||||||
|
key: &str,
|
||||||
|
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let expire_at_str = expire_at.map(|dt| dt.to_rfc3339());
|
||||||
|
let target_entry = CacheActiveModel {
|
||||||
|
// pks
|
||||||
|
category: Set(category.to_string()),
|
||||||
|
key: Set(key.to_string()),
|
||||||
|
// update
|
||||||
|
expire_at: Set(expire_at_str),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
CacheEntity::update(target_entry)
|
||||||
|
.validate()?
|
||||||
|
.exec(&self.db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src-tauri/src/services/cache/service.rs
vendored
Normal file
123
src-tauri/src/services/cache/service.rs
vendored
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
|
use sea_orm::DatabaseConnection;
|
||||||
|
|
||||||
|
use super::dto::CacheCategory;
|
||||||
|
use crate::{
|
||||||
|
db::Db,
|
||||||
|
services::cache::{CacheServiceError, CacheValue},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub type CacheResult<T> = std::result::Result<T, CacheServiceError>;
|
||||||
|
type Result<T> = CacheResult<T>;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait CacheService: Send + Sync + 'static {
|
||||||
|
async fn get_category(&self, category: CacheCategory) -> Result<HashMap<String, CacheValue>>;
|
||||||
|
async fn get(&self, category: CacheCategory, key: &str) -> Result<CacheValue>;
|
||||||
|
async fn set_with_tx(
|
||||||
|
&self,
|
||||||
|
tx: &Db,
|
||||||
|
category: CacheCategory,
|
||||||
|
key: &str,
|
||||||
|
value: &str,
|
||||||
|
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn set(
|
||||||
|
&self,
|
||||||
|
category: CacheCategory,
|
||||||
|
key: &str,
|
||||||
|
value: &str,
|
||||||
|
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn invalidate_category(&self, category: CacheCategory) -> Result<()>;
|
||||||
|
async fn invalidate_with_tx(&self, tx: &Db, category: CacheCategory, key: &str) -> Result<()>;
|
||||||
|
async fn invalidate(&self, category: CacheCategory, key: &str) -> Result<()>;
|
||||||
|
async fn set_expire(
|
||||||
|
&self,
|
||||||
|
category: CacheCategory,
|
||||||
|
key: &str,
|
||||||
|
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
) -> Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CacheServiceImpl<T>
|
||||||
|
where
|
||||||
|
T: super::repo::CacheRepo + ?Sized,
|
||||||
|
{
|
||||||
|
db: DatabaseConnection,
|
||||||
|
repo: Arc<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CacheServiceImpl<super::repo::CacheRepoImpl> {
|
||||||
|
pub fn new(db: DatabaseConnection) -> Self {
|
||||||
|
Self {
|
||||||
|
db: db.clone(),
|
||||||
|
repo: Arc::new(super::repo::CacheRepoImpl::new(db)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl<T> CacheService for CacheServiceImpl<T>
|
||||||
|
where
|
||||||
|
T: super::repo::CacheRepo + ?Sized,
|
||||||
|
{
|
||||||
|
async fn get_category(&self, category: CacheCategory) -> Result<HashMap<String, CacheValue>> {
|
||||||
|
let cache_entries = self.repo.get_category(category).await?;
|
||||||
|
|
||||||
|
let result = cache_entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, model)| (key, model.into()))
|
||||||
|
.collect();
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, category: CacheCategory, key: &str) -> Result<CacheValue> {
|
||||||
|
let cache_entry = self.repo.get(category, key).await?;
|
||||||
|
Ok(cache_entry.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set(
|
||||||
|
&self,
|
||||||
|
category: CacheCategory,
|
||||||
|
key: &str,
|
||||||
|
value: &str,
|
||||||
|
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.repo.set(category, key, value, expire_at).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_with_tx(
|
||||||
|
&self,
|
||||||
|
tx: &Db,
|
||||||
|
category: CacheCategory,
|
||||||
|
key: &str,
|
||||||
|
value: &str,
|
||||||
|
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.repo
|
||||||
|
.set_with_tx(tx, category, key, value, expire_at)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invalidate_category(&self, category: CacheCategory) -> Result<()> {
|
||||||
|
self.repo.invalidate_category(category).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invalidate(&self, category: CacheCategory, key: &str) -> Result<()> {
|
||||||
|
self.repo.invalidate(category, key).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invalidate_with_tx(&self, tx: &Db, category: CacheCategory, key: &str) -> Result<()> {
|
||||||
|
self.repo.invalidate_with_tx(tx, category, key).await
|
||||||
|
}
|
||||||
|
async fn set_expire(
|
||||||
|
&self,
|
||||||
|
category: CacheCategory,
|
||||||
|
key: &str,
|
||||||
|
expire_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.repo.set_expire(category, key, expire_at).await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use crate::services::{
|
|||||||
AccountsService, AccountsServiceImpl,
|
AccountsService, AccountsServiceImpl,
|
||||||
category::{AccountCategoryService, AccountCategoryServiceImpl},
|
category::{AccountCategoryService, AccountCategoryServiceImpl},
|
||||||
},
|
},
|
||||||
|
cache::{CacheService, CacheServiceImpl},
|
||||||
transaction::{
|
transaction::{
|
||||||
TransactionService, TransactionServiceImpl,
|
TransactionService, TransactionServiceImpl,
|
||||||
category::{CategoryService, CategoryServiceImpl},
|
category::{CategoryService, CategoryServiceImpl},
|
||||||
@@ -15,6 +16,7 @@ use crate::services::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub mod accounts;
|
pub mod accounts;
|
||||||
|
pub mod cache;
|
||||||
pub mod transaction;
|
pub mod transaction;
|
||||||
|
|
||||||
pub type AppState = Services<
|
pub type AppState = Services<
|
||||||
@@ -23,22 +25,25 @@ pub type AppState = Services<
|
|||||||
dyn TransactionService,
|
dyn TransactionService,
|
||||||
dyn CategoryService,
|
dyn CategoryService,
|
||||||
dyn TagService,
|
dyn TagService,
|
||||||
|
dyn CacheService,
|
||||||
>;
|
>;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Services<AC, A, T, TC, TT>
|
pub struct Services<AC, A, T, TC, TT, C>
|
||||||
where
|
where
|
||||||
AC: AccountCategoryService + ?Sized,
|
AC: AccountCategoryService + ?Sized,
|
||||||
A: AccountsService + ?Sized,
|
A: AccountsService + ?Sized,
|
||||||
T: TransactionService + ?Sized,
|
T: TransactionService + ?Sized,
|
||||||
TC: CategoryService + ?Sized,
|
TC: CategoryService + ?Sized,
|
||||||
TT: TagService + ?Sized,
|
TT: TagService + ?Sized,
|
||||||
|
C: CacheService + ?Sized,
|
||||||
{
|
{
|
||||||
pub account_category: Arc<AC>,
|
pub account_category: Arc<AC>,
|
||||||
pub accounts: Arc<A>,
|
pub accounts: Arc<A>,
|
||||||
pub transaction: Arc<T>,
|
pub transaction: Arc<T>,
|
||||||
pub transaction_category: Arc<TC>,
|
pub transaction_category: Arc<TC>,
|
||||||
pub transaction_tag: Arc<TT>,
|
pub transaction_tag: Arc<TT>,
|
||||||
|
pub cache: Arc<C>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_services(db: DatabaseConnection) -> AppState {
|
pub fn create_services(db: DatabaseConnection) -> AppState {
|
||||||
@@ -51,6 +56,8 @@ pub fn create_services(db: DatabaseConnection) -> AppState {
|
|||||||
let transaction_category_service: Arc<dyn CategoryService> =
|
let transaction_category_service: Arc<dyn CategoryService> =
|
||||||
Arc::new(CategoryServiceImpl::new(db.clone()));
|
Arc::new(CategoryServiceImpl::new(db.clone()));
|
||||||
let transaction_tag_service: Arc<dyn TagService> = Arc::new(TagServiceImpl::new(db.clone()));
|
let transaction_tag_service: Arc<dyn TagService> = Arc::new(TagServiceImpl::new(db.clone()));
|
||||||
|
let cache_service: Arc<dyn CacheService> = Arc::new(CacheServiceImpl::new(db.clone()));
|
||||||
|
|
||||||
Services {
|
Services {
|
||||||
account_category: account_category_service.clone(),
|
account_category: account_category_service.clone(),
|
||||||
accounts: account_service.clone(),
|
accounts: account_service.clone(),
|
||||||
@@ -62,5 +69,6 @@ pub fn create_services(db: DatabaseConnection) -> AppState {
|
|||||||
)) as Arc<dyn TransactionService>,
|
)) as Arc<dyn TransactionService>,
|
||||||
transaction_category: transaction_category_service,
|
transaction_category: transaction_category_service,
|
||||||
transaction_tag: transaction_tag_service,
|
transaction_tag: transaction_tag_service,
|
||||||
|
cache: cache_service,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
db::{
|
db::{
|
||||||
entities::category::{ActiveModel as CategoryActiveModel, Entity as CategoryEntity},
|
|
||||||
Db,
|
Db,
|
||||||
|
entities::category::{ActiveModel as CategoryActiveModel, Entity as CategoryEntity},
|
||||||
},
|
},
|
||||||
services::transaction::category::{
|
services::transaction::category::{
|
||||||
Category, CategoryServiceError, CategoryServiceResult, CategoryWithParent,
|
Category, CategoryServiceError, CategoryServiceResult, CategoryWithParent,
|
||||||
@@ -22,7 +22,7 @@ pub trait CategoryRepoService: Send + Sync + 'static {
|
|||||||
async fn get_category(&self, id: i64) -> Result<Option<CategoryWithParent>>;
|
async fn get_category(&self, id: i64) -> Result<Option<CategoryWithParent>>;
|
||||||
async fn create_category(&self, request: CreateCategoryRequest) -> Result<i64>;
|
async fn create_category(&self, request: CreateCategoryRequest) -> Result<i64>;
|
||||||
async fn create_category_with_tx(&self, request: CreateCategoryRequest, tx: &Db)
|
async fn create_category_with_tx(&self, request: CreateCategoryRequest, tx: &Db)
|
||||||
-> Result<i64>;
|
-> Result<i64>;
|
||||||
async fn update_category(&self, id: i64, request: UpdateCategoryRequest) -> Result<()>;
|
async fn update_category(&self, id: i64, request: UpdateCategoryRequest) -> Result<()>;
|
||||||
async fn update_category_with_tx(
|
async fn update_category_with_tx(
|
||||||
&self,
|
&self,
|
||||||
@@ -35,7 +35,13 @@ pub trait CategoryRepoService: Send + Sync + 'static {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct CategoryRepoServiceImpl {
|
pub struct CategoryRepoServiceImpl {
|
||||||
pub db: DatabaseConnection,
|
db: DatabaseConnection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CategoryRepoServiceImpl {
|
||||||
|
pub fn new(db: DatabaseConnection) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CategorySelfRefLink;
|
struct CategorySelfRefLink;
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ use thiserror::Error;
|
|||||||
use crate::{
|
use crate::{
|
||||||
db::{Db, DbServiceError},
|
db::{Db, DbServiceError},
|
||||||
services::transaction::category::{
|
services::transaction::category::{
|
||||||
repo::{CategoryRepoService, CategoryRepoServiceImpl},
|
|
||||||
Category, CategoryWithParent, CreateCategoryRequest, UpdateCategoryRequest,
|
Category, CategoryWithParent, CreateCategoryRequest, UpdateCategoryRequest,
|
||||||
|
repo::{CategoryRepoService, CategoryRepoServiceImpl},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ pub trait CategoryService: Send + Sync + 'static {
|
|||||||
async fn get_category(&self, id: i64) -> Result<Option<CategoryWithParent>>;
|
async fn get_category(&self, id: i64) -> Result<Option<CategoryWithParent>>;
|
||||||
async fn create_category(&self, request: CreateCategoryRequest) -> Result<i64>;
|
async fn create_category(&self, request: CreateCategoryRequest) -> Result<i64>;
|
||||||
async fn create_category_with_tx(&self, request: CreateCategoryRequest, tx: &Db)
|
async fn create_category_with_tx(&self, request: CreateCategoryRequest, tx: &Db)
|
||||||
-> Result<i64>;
|
-> Result<i64>;
|
||||||
async fn update_category(&self, id: i64, request: UpdateCategoryRequest) -> Result<()>;
|
async fn update_category(&self, id: i64, request: UpdateCategoryRequest) -> Result<()>;
|
||||||
async fn update_category_with_tx(
|
async fn update_category_with_tx(
|
||||||
&self,
|
&self,
|
||||||
@@ -62,7 +62,7 @@ impl CategoryServiceImpl<CategoryRepoServiceImpl> {
|
|||||||
pub fn new(db: DatabaseConnection) -> Self {
|
pub fn new(db: DatabaseConnection) -> Self {
|
||||||
Self {
|
Self {
|
||||||
db: db.clone(),
|
db: db.clone(),
|
||||||
category_repo: Arc::new(CategoryRepoServiceImpl { db }),
|
category_repo: Arc::new(CategoryRepoServiceImpl::new(db)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ use crate::{
|
|||||||
DbServiceError,
|
DbServiceError,
|
||||||
entities::{account::Column::Id, transaction::Model as TransactionModel},
|
entities::{account::Column::Id, transaction::Model as TransactionModel},
|
||||||
},
|
},
|
||||||
services::transaction::{category::CategoryServiceError, tag::TagServiceError},
|
services::{
|
||||||
|
accounts::{Account, AccountsServiceError},
|
||||||
|
transaction::{category::CategoryServiceError, tag::TagServiceError},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::db::{
|
use crate::db::{
|
||||||
@@ -61,6 +64,19 @@ impl From<CategoryServiceError> for TransactionServiceError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<AccountsServiceError> for TransactionServiceError {
|
||||||
|
fn from(err: AccountsServiceError) -> Self {
|
||||||
|
match err {
|
||||||
|
AccountsServiceError::DbErr(db_err) => TransactionServiceError::DbErr(db_err),
|
||||||
|
AccountsServiceError::TargetNotFound(msg) => {
|
||||||
|
TransactionServiceError::TargetNotFound(msg)
|
||||||
|
}
|
||||||
|
AccountsServiceError::Validation(msg) => TransactionServiceError::Validation(msg),
|
||||||
|
AccountsServiceError::Unknown(msg) => TransactionServiceError::Unknown(msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum TransactionType {
|
pub enum TransactionType {
|
||||||
#[serde(rename = "STANDARD")]
|
#[serde(rename = "STANDARD")]
|
||||||
@@ -158,12 +174,7 @@ async fn validate_account_exists<T>(
|
|||||||
where
|
where
|
||||||
T: crate::services::accounts::AccountsService + ?Sized,
|
T: crate::services::accounts::AccountsService + ?Sized,
|
||||||
{
|
{
|
||||||
let account_exists = account_service
|
let account_exists = account_service.get_account(&account_id).await?.is_some();
|
||||||
.get_account(&account_id)
|
|
||||||
.await
|
|
||||||
// TODO: add error from account service to error chain
|
|
||||||
.map_err(|err| TransactionServiceError::Unknown(err))?
|
|
||||||
.is_some();
|
|
||||||
if !account_exists {
|
if !account_exists {
|
||||||
return Err(TransactionServiceError::Validation(format!(
|
return Err(TransactionServiceError::Validation(format!(
|
||||||
"Account with id {} does not exist",
|
"Account with id {} does not exist",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
db::{
|
db::{
|
||||||
|
Db,
|
||||||
entities::{
|
entities::{
|
||||||
tag::{ActiveModel as TagActiveModel, Entity as TagEntity},
|
tag::{ActiveModel as TagActiveModel, Entity as TagEntity},
|
||||||
transaction_tag::{
|
transaction_tag::{
|
||||||
@@ -7,7 +8,6 @@ use crate::{
|
|||||||
Entity as TransactionTagEntity,
|
Entity as TransactionTagEntity,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Db,
|
|
||||||
},
|
},
|
||||||
services::transaction::tag::{
|
services::transaction::tag::{
|
||||||
CreateTagRequest, Tag, TagServiceError, TagServiceResult, UpdateTagRequest,
|
CreateTagRequest, Tag, TagServiceError, TagServiceResult, UpdateTagRequest,
|
||||||
@@ -45,7 +45,13 @@ pub trait TagRepoService: Send + Sync + 'static {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct TagRepoServiceImpl {
|
pub struct TagRepoServiceImpl {
|
||||||
pub db: DatabaseConnection,
|
db: DatabaseConnection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TagRepoServiceImpl {
|
||||||
|
pub fn new(db: DatabaseConnection) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ use thiserror::Error;
|
|||||||
use crate::{
|
use crate::{
|
||||||
db::{Db, DbServiceError},
|
db::{Db, DbServiceError},
|
||||||
services::transaction::tag::{
|
services::transaction::tag::{
|
||||||
repo::{TagRepoService, TagRepoServiceImpl},
|
|
||||||
CreateTagRequest, Tag, UpdateTagRequest,
|
CreateTagRequest, Tag, UpdateTagRequest,
|
||||||
|
repo::{TagRepoService, TagRepoServiceImpl},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ impl TagServiceImpl<TagRepoServiceImpl> {
|
|||||||
pub fn new(db: DatabaseConnection) -> Self {
|
pub fn new(db: DatabaseConnection) -> Self {
|
||||||
Self {
|
Self {
|
||||||
db: db.clone(),
|
db: db.clone(),
|
||||||
tag_repo: Arc::new(TagRepoServiceImpl { db }),
|
tag_repo: Arc::new(TagRepoServiceImpl::new(db)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
416
src/App copy.tsx
416
src/App copy.tsx
@@ -1,416 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
|
||||||
import './App.css';
|
|
||||||
|
|
||||||
interface Account {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
account_type: string;
|
|
||||||
account_category: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Transaction {
|
|
||||||
id: number;
|
|
||||||
account_id: number;
|
|
||||||
amount: string;
|
|
||||||
transaction_type: string;
|
|
||||||
currency_code: string;
|
|
||||||
exchange_rate: string | null;
|
|
||||||
transaction_date: string;
|
|
||||||
from_account_id: number | null;
|
|
||||||
category_id: number | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [activeTab, setActiveTab] = useState<'accounts' | 'transactions'>('accounts');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [success, setSuccess] = useState('');
|
|
||||||
|
|
||||||
// Account states
|
|
||||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
|
||||||
const [accountName, setAccountName] = useState('');
|
|
||||||
const [accountType, setAccountType] = useState('Asset');
|
|
||||||
const [accountCategory, setAccountCategory] = useState('');
|
|
||||||
const [selectedAccountId, setSelectedAccountId] = useState('');
|
|
||||||
|
|
||||||
// Transaction states
|
|
||||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
|
||||||
const [txAccountId, setTxAccountId] = useState('');
|
|
||||||
const [txAmount, setTxAmount] = useState('');
|
|
||||||
const [txType, setTxType] = useState('STANDARD');
|
|
||||||
const [txCurrency, setTxCurrency] = useState('USD');
|
|
||||||
const [txExchangeRate, setTxExchangeRate] = useState('');
|
|
||||||
const [txDate, setTxDate] = useState(new Date().toISOString());
|
|
||||||
const [txFromAccountId, setTxFromAccountId] = useState('');
|
|
||||||
const [txCategoryId, setTxCategoryId] = useState('');
|
|
||||||
const [selectedTransactionId, setSelectedTransactionId] = useState('');
|
|
||||||
const [updateTxAmount, setUpdateTxAmount] = useState('');
|
|
||||||
|
|
||||||
const clearMessages = () => {
|
|
||||||
setError('');
|
|
||||||
setSuccess('');
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== ACCOUNT COMMANDS ====================
|
|
||||||
|
|
||||||
const handleGetAccounts = async () => {
|
|
||||||
clearMessages();
|
|
||||||
try {
|
|
||||||
const result = await invoke<Account[]>('get_accounts');
|
|
||||||
setAccounts(result);
|
|
||||||
setSuccess('Accounts fetched successfully');
|
|
||||||
} catch (err) {
|
|
||||||
setError(`Error: ${err}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGetAccount = async () => {
|
|
||||||
clearMessages();
|
|
||||||
if (!selectedAccountId) {
|
|
||||||
setError('Please enter an account ID');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await invoke<Account | null>('get_account', {
|
|
||||||
id: parseInt(selectedAccountId),
|
|
||||||
});
|
|
||||||
if (result) {
|
|
||||||
setAccounts([result]);
|
|
||||||
setSuccess('Account fetched successfully');
|
|
||||||
} else {
|
|
||||||
setError('Account not found');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(`Error: ${err}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateAccount = async () => {
|
|
||||||
clearMessages();
|
|
||||||
if (!accountName || !accountCategory) {
|
|
||||||
setError('Please fill in all account fields');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await invoke<number>('create_account', {
|
|
||||||
request: {
|
|
||||||
name: accountName,
|
|
||||||
category: {
|
|
||||||
name: accountCategory,
|
|
||||||
account_type: accountType,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setSuccess(`Account created with ID: ${result}`);
|
|
||||||
setAccountName('');
|
|
||||||
setAccountCategory('');
|
|
||||||
setAccountType('Asset');
|
|
||||||
await handleGetAccounts();
|
|
||||||
} catch (err) {
|
|
||||||
setError(`Error: ${err}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==================== TRANSACTION COMMANDS ====================
|
|
||||||
|
|
||||||
const handleGetTransactionsForAccount = async () => {
|
|
||||||
clearMessages();
|
|
||||||
if (!txAccountId) {
|
|
||||||
setError('Please enter an account ID');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await invoke<Transaction[]>('get_transactions_for_account', {
|
|
||||||
id: parseInt(txAccountId),
|
|
||||||
});
|
|
||||||
setTransactions(result);
|
|
||||||
setSuccess('Transactions fetched successfully');
|
|
||||||
} catch (err) {
|
|
||||||
setError(`Error: ${err}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateTransaction = async () => {
|
|
||||||
clearMessages();
|
|
||||||
if (!txAccountId || !txAmount || !txCurrency) {
|
|
||||||
setError('Please fill in all required transaction fields');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await invoke<number>('create_transaction', {
|
|
||||||
request: {
|
|
||||||
account_id: parseInt(txAccountId),
|
|
||||||
amount: txAmount,
|
|
||||||
transaction_type: txType,
|
|
||||||
currency_code: txCurrency,
|
|
||||||
exchange_rate: txExchangeRate || null,
|
|
||||||
transaction_date: txDate,
|
|
||||||
metadata: null,
|
|
||||||
from_account_id: txFromAccountId ? parseInt(txFromAccountId) : null,
|
|
||||||
category_id: txCategoryId ? parseInt(txCategoryId) : null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setSuccess(`Transaction created with ID: ${result}`);
|
|
||||||
setTxAmount('');
|
|
||||||
setTxFromAccountId('');
|
|
||||||
setTxCategoryId('');
|
|
||||||
setTxExchangeRate('');
|
|
||||||
setTxDate(new Date().toISOString());
|
|
||||||
await handleGetTransactionsForAccount();
|
|
||||||
} catch (err) {
|
|
||||||
setError(`Error: ${err}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateTransaction = async () => {
|
|
||||||
clearMessages();
|
|
||||||
if (!selectedTransactionId) {
|
|
||||||
setError('Please select a transaction to update');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!updateTxAmount) {
|
|
||||||
setError('Please enter an amount to update');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await invoke('update_transaction', {
|
|
||||||
id: parseInt(selectedTransactionId),
|
|
||||||
request: {
|
|
||||||
amount: updateTxAmount,
|
|
||||||
account_id: null,
|
|
||||||
transaction_type: null,
|
|
||||||
currency_code: null,
|
|
||||||
exchange_rate: null,
|
|
||||||
transaction_date: null,
|
|
||||||
metadata: null,
|
|
||||||
from_account_id: null,
|
|
||||||
category_id: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setSuccess('Transaction updated successfully');
|
|
||||||
setUpdateTxAmount('');
|
|
||||||
setSelectedTransactionId('');
|
|
||||||
await handleGetTransactionsForAccount();
|
|
||||||
} catch (err) {
|
|
||||||
setError(`Error: ${err}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="container">
|
|
||||||
<h1>Otter - Command Testing UI</h1>
|
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
|
||||||
<div className="tabs">
|
|
||||||
<button className={`tab-button ${activeTab === 'accounts' ? 'active' : ''}`} onClick={() => setActiveTab('accounts')}>
|
|
||||||
Accounts
|
|
||||||
</button>
|
|
||||||
<button className={`tab-button ${activeTab === 'transactions' ? 'active' : ''}`} onClick={() => setActiveTab('transactions')}>
|
|
||||||
Transactions
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Messages */}
|
|
||||||
{error && <div className="message error">{error}</div>}
|
|
||||||
{success && <div className="message success">{success}</div>}
|
|
||||||
|
|
||||||
{/* ACCOUNTS TAB */}
|
|
||||||
{activeTab === 'accounts' && (
|
|
||||||
<div className="tab-content">
|
|
||||||
<h2>Account Management</h2>
|
|
||||||
|
|
||||||
{/* Create Account Form */}
|
|
||||||
<div className="form-section">
|
|
||||||
<h3>Create Account</h3>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Account Name:</label>
|
|
||||||
<input type="text" value={accountName} onChange={(e) => setAccountName(e.target.value)} placeholder="e.g., My Checking Account" />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Category Name:</label>
|
|
||||||
<input type="text" value={accountCategory} onChange={(e) => setAccountCategory(e.target.value)} placeholder="e.g., Checking" />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Account Type:</label>
|
|
||||||
<select value={accountType} onChange={(e) => setAccountType(e.target.value)}>
|
|
||||||
<option value="Asset">Asset</option>
|
|
||||||
<option value="Liability">Liability</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button onClick={handleCreateAccount} className="btn-primary">
|
|
||||||
Create Account
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Get Single Account */}
|
|
||||||
<div className="form-section">
|
|
||||||
<h3>Get Single Account</h3>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Account ID:</label>
|
|
||||||
<input type="number" value={selectedAccountId} onChange={(e) => setSelectedAccountId(e.target.value)} placeholder="Enter account ID" />
|
|
||||||
</div>
|
|
||||||
<button onClick={handleGetAccount} className="btn-primary">
|
|
||||||
Get Account
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Get All Accounts */}
|
|
||||||
<div className="form-section">
|
|
||||||
<button onClick={handleGetAccounts} className="btn-primary">
|
|
||||||
Get All Accounts
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Accounts Table */}
|
|
||||||
{accounts.length > 0 && (
|
|
||||||
<div className="table-section">
|
|
||||||
<h3>Accounts</h3>
|
|
||||||
<table className="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>Category</th>
|
|
||||||
<th>Created At</th>
|
|
||||||
<th>Updated At</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{accounts.map((account) => (
|
|
||||||
<tr key={account.id}>
|
|
||||||
<td>{account.id}</td>
|
|
||||||
<td>{account.name}</td>
|
|
||||||
<td>{account.account_type}</td>
|
|
||||||
<td>{account.account_category}</td>
|
|
||||||
<td>{new Date(account.created_at).toLocaleString()}</td>
|
|
||||||
<td>{new Date(account.updated_at).toLocaleString()}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* TRANSACTIONS TAB */}
|
|
||||||
{activeTab === 'transactions' && (
|
|
||||||
<div className="tab-content">
|
|
||||||
<h2>Transaction Management</h2>
|
|
||||||
|
|
||||||
{/* Create Transaction Form */}
|
|
||||||
<div className="form-section">
|
|
||||||
<h3>Create Transaction</h3>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Account ID:</label>
|
|
||||||
<input type="number" value={txAccountId} onChange={(e) => setTxAccountId(e.target.value)} placeholder="Account ID" />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Amount:</label>
|
|
||||||
<input type="text" value={txAmount} onChange={(e) => setTxAmount(e.target.value)} placeholder="e.g., 100.50" />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Type:</label>
|
|
||||||
<select value={txType} onChange={(e) => setTxType(e.target.value)}>
|
|
||||||
<option value="STANDARD">STANDARD</option>
|
|
||||||
<option value="RECONCILIATION">RECONCILIATION</option>
|
|
||||||
<option value="OPENING_BALANCE">OPENING_BALANCE</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Currency Code:</label>
|
|
||||||
<input type="text" value={txCurrency} onChange={(e) => setTxCurrency(e.target.value)} placeholder="USD" />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Exchange Rate (Optional):</label>
|
|
||||||
<input type="text" value={txExchangeRate} onChange={(e) => setTxExchangeRate(e.target.value)} placeholder="1.0" />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Transaction Date:</label>
|
|
||||||
<input type="datetime-local" value={txDate.slice(0, 16)} onChange={(e) => setTxDate(new Date(e.target.value).toISOString())} />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>From Account ID (Optional):</label>
|
|
||||||
<input type="number" value={txFromAccountId} onChange={(e) => setTxFromAccountId(e.target.value)} placeholder="Source account ID" />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Category ID (Optional):</label>
|
|
||||||
<input type="number" value={txCategoryId} onChange={(e) => setTxCategoryId(e.target.value)} placeholder="Category ID" />
|
|
||||||
</div>
|
|
||||||
<button onClick={handleCreateTransaction} className="btn-primary">
|
|
||||||
Create Transaction
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Update Transaction Form */}
|
|
||||||
<div className="form-section">
|
|
||||||
<h3>Update Transaction</h3>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Transaction ID:</label>
|
|
||||||
<input type="number" value={selectedTransactionId} onChange={(e) => setSelectedTransactionId(e.target.value)} placeholder="Enter transaction ID" />
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>New Amount:</label>
|
|
||||||
<input type="text" value={updateTxAmount} onChange={(e) => setUpdateTxAmount(e.target.value)} placeholder="e.g., 150.75" />
|
|
||||||
</div>
|
|
||||||
<button onClick={handleUpdateTransaction} className="btn-primary">
|
|
||||||
Update Transaction
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Get Transactions for Account */}
|
|
||||||
<div className="form-section">
|
|
||||||
<h3>Get Transactions for Account</h3>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Account ID:</label>
|
|
||||||
<input type="number" value={txAccountId} onChange={(e) => setTxAccountId(e.target.value)} placeholder="Enter account ID" />
|
|
||||||
</div>
|
|
||||||
<button onClick={handleGetTransactionsForAccount} className="btn-primary">
|
|
||||||
Get Transactions
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Transactions Table */}
|
|
||||||
{transactions.length > 0 && (
|
|
||||||
<div className="table-section">
|
|
||||||
<h3>Transactions</h3>
|
|
||||||
<table className="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>Account ID</th>
|
|
||||||
<th>Amount</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>Currency</th>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Created At</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{transactions.map((tx) => (
|
|
||||||
<tr key={tx.id}>
|
|
||||||
<td>{tx.id}</td>
|
|
||||||
<td>{tx.account_id}</td>
|
|
||||||
<td>{tx.amount}</td>
|
|
||||||
<td>{tx.transaction_type}</td>
|
|
||||||
<td>{tx.currency_code}</td>
|
|
||||||
<td>{new Date(tx.transaction_date).toLocaleString()}</td>
|
|
||||||
<td>{new Date(tx.created_at).toLocaleString()}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
440
src/App.css
440
src/App.css
@@ -1,10 +1,10 @@
|
|||||||
@import 'tailwindcss';
|
.logo.vite:hover {
|
||||||
@import "tw-animate-css";
|
filter: drop-shadow(0 0 2em #747bff);
|
||||||
@import "shadcn/tailwind.css";
|
}
|
||||||
@import "@fontsource-variable/inter";
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
|
||||||
|
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafb);
|
||||||
|
}
|
||||||
:root {
|
:root {
|
||||||
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -19,234 +19,80 @@
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
--background: oklch(1 0 0);
|
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
|
||||||
--card: oklch(1 0 0);
|
|
||||||
--card-foreground: oklch(0.141 0.005 285.823);
|
|
||||||
--popover: oklch(1 0 0);
|
|
||||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
|
||||||
--primary: oklch(0.52 0.105 223.128);
|
|
||||||
--primary-foreground: oklch(0.984 0.019 200.873);
|
|
||||||
--secondary: oklch(0.967 0.001 286.375);
|
|
||||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
|
||||||
--muted: oklch(0.967 0.001 286.375);
|
|
||||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
|
||||||
--accent: oklch(0.967 0.001 286.375);
|
|
||||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
|
||||||
--border: oklch(0.92 0.004 286.32);
|
|
||||||
--input: oklch(0.92 0.004 286.32);
|
|
||||||
--ring: oklch(0.705 0.015 286.067);
|
|
||||||
--chart-1: oklch(0.809 0.105 251.813);
|
|
||||||
--chart-2: oklch(0.623 0.214 259.815);
|
|
||||||
--chart-3: oklch(0.546 0.245 262.881);
|
|
||||||
--chart-4: oklch(0.488 0.243 264.376);
|
|
||||||
--chart-5: oklch(0.424 0.199 265.638);
|
|
||||||
--radius: 0.625rem;
|
|
||||||
--sidebar: oklch(0.985 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
|
||||||
--sidebar-primary: oklch(0.609 0.126 221.723);
|
|
||||||
--sidebar-primary-foreground: oklch(0.984 0.019 200.873);
|
|
||||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
|
||||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
|
||||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
|
||||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1400px;
|
margin: 0;
|
||||||
margin: 0 auto;
|
padding-top: 10vh;
|
||||||
padding: 2rem;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: 0.75s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo.tauri:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #24c8db);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #646cff;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: #535bf2;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
color: #333;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
input,
|
||||||
color: #555;
|
button {
|
||||||
margin-top: 2rem;
|
border-radius: 8px;
|
||||||
margin-bottom: 1rem;
|
border: 1px solid transparent;
|
||||||
}
|
padding: 0.6em 1.2em;
|
||||||
|
font-size: 1em;
|
||||||
h3 {
|
|
||||||
color: #777;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tabs */
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
border-bottom: 2px solid #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-button {
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: #666;
|
|
||||||
border-bottom: 3px solid transparent;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-button:hover {
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-button.active {
|
|
||||||
color: #007bff;
|
|
||||||
border-bottom-color: #007bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Messages */
|
|
||||||
.message {
|
|
||||||
padding: 1rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
|
||||||
|
|
||||||
.message.error {
|
|
||||||
background-color: #fee;
|
|
||||||
color: #c33;
|
|
||||||
border-left: 4px solid #c33;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message.success {
|
|
||||||
background-color: #efe;
|
|
||||||
color: #3c3;
|
|
||||||
border-left: 4px solid #3c3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Forms */
|
|
||||||
.form-section {
|
|
||||||
background: #f9f9f9;
|
|
||||||
padding: 1.5rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input,
|
|
||||||
.form-group select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
transition: border-color 0.3s;
|
color: #0f0f0f;
|
||||||
|
background-color: #ffffff;
|
||||||
|
transition: border-color 0.25s;
|
||||||
|
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input:focus,
|
button {
|
||||||
.form-group select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #007bff;
|
|
||||||
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input::placeholder {
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
.btn-primary {
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
background-color: #007bff;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
button:hover {
|
||||||
background-color: #0056b3;
|
border-color: #396cd8;
|
||||||
box-shadow: 0 2px 8px rgba(0, 86, 179, 0.3);
|
}
|
||||||
|
button:active {
|
||||||
|
border-color: #396cd8;
|
||||||
|
background-color: #e8e8e8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:active {
|
input,
|
||||||
transform: translateY(1px);
|
button {
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tables */
|
#greet-input {
|
||||||
.table-section {
|
margin-right: 5px;
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
background: white;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table thead {
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
border-bottom: 2px solid #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table th {
|
|
||||||
padding: 1rem;
|
|
||||||
text-align: left;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table td {
|
|
||||||
padding: 1rem;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table tbody tr:hover {
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table tbody tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tab content */
|
|
||||||
.tab-content {
|
|
||||||
animation: fadeIn 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
@@ -255,162 +101,16 @@ h3 {
|
|||||||
background-color: #2f2f2f;
|
background-color: #2f2f2f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
a:hover {
|
||||||
color: #f6f6f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3 {
|
|
||||||
color: #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-section {
|
|
||||||
background: #3a3a3a;
|
|
||||||
border-color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
color: #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input,
|
|
||||||
.form-group select {
|
|
||||||
background-color: #2f2f2f;
|
|
||||||
color: #f6f6f6;
|
|
||||||
border-color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:focus,
|
|
||||||
.form-group select:focus {
|
|
||||||
border-color: #007bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table {
|
|
||||||
background: #3a3a3a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table thead {
|
|
||||||
background-color: #2f2f2f;
|
|
||||||
border-bottom-color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table th {
|
|
||||||
color: #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table td {
|
|
||||||
color: #d0d0d0;
|
|
||||||
border-bottom-color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table tbody tr:hover {
|
|
||||||
background-color: #454545;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs {
|
|
||||||
border-bottom-color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-button {
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-button:hover {
|
|
||||||
color: #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-button.active {
|
|
||||||
color: #24c8db;
|
color: #24c8db;
|
||||||
border-bottom-color: #24c8db;
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
button {
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: #0f0f0f98;
|
||||||
|
}
|
||||||
|
button:active {
|
||||||
|
background-color: #0f0f0f69;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
--font-heading: var(--font-sans);
|
|
||||||
--font-sans: 'Inter Variable', sans-serif;
|
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
||||||
--color-sidebar: var(--sidebar);
|
|
||||||
--color-chart-5: var(--chart-5);
|
|
||||||
--color-chart-4: var(--chart-4);
|
|
||||||
--color-chart-3: var(--chart-3);
|
|
||||||
--color-chart-2: var(--chart-2);
|
|
||||||
--color-chart-1: var(--chart-1);
|
|
||||||
--color-ring: var(--ring);
|
|
||||||
--color-input: var(--input);
|
|
||||||
--color-border: var(--border);
|
|
||||||
--color-destructive: var(--destructive);
|
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
|
||||||
--color-accent: var(--accent);
|
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
|
||||||
--color-muted: var(--muted);
|
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
|
||||||
--color-secondary: var(--secondary);
|
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
|
||||||
--color-primary: var(--primary);
|
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
|
||||||
--color-popover: var(--popover);
|
|
||||||
--color-card-foreground: var(--card-foreground);
|
|
||||||
--color-card: var(--card);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--color-background: var(--background);
|
|
||||||
--radius-sm: calc(var(--radius) * 0.6);
|
|
||||||
--radius-md: calc(var(--radius) * 0.8);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) * 1.4);
|
|
||||||
--radius-2xl: calc(var(--radius) * 1.8);
|
|
||||||
--radius-3xl: calc(var(--radius) * 2.2);
|
|
||||||
--radius-4xl: calc(var(--radius) * 2.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: oklch(0.141 0.005 285.823);
|
|
||||||
--foreground: oklch(0.985 0 0);
|
|
||||||
--card: oklch(0.21 0.006 285.885);
|
|
||||||
--card-foreground: oklch(0.985 0 0);
|
|
||||||
--popover: oklch(0.21 0.006 285.885);
|
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
|
||||||
--primary: oklch(0.45 0.085 224.283);
|
|
||||||
--primary-foreground: oklch(0.984 0.019 200.873);
|
|
||||||
--secondary: oklch(0.274 0.006 286.033);
|
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
|
||||||
--muted: oklch(0.274 0.006 286.033);
|
|
||||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
|
||||||
--accent: oklch(0.274 0.006 286.033);
|
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
|
||||||
--border: oklch(1 0 0 / 10%);
|
|
||||||
--input: oklch(1 0 0 / 15%);
|
|
||||||
--ring: oklch(0.552 0.016 285.938);
|
|
||||||
--chart-1: oklch(0.809 0.105 251.813);
|
|
||||||
--chart-2: oklch(0.623 0.214 259.815);
|
|
||||||
--chart-3: oklch(0.546 0.245 262.881);
|
|
||||||
--chart-4: oklch(0.488 0.243 264.376);
|
|
||||||
--chart-5: oklch(0.424 0.199 265.638);
|
|
||||||
--sidebar: oklch(0.21 0.006 285.885);
|
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-primary: oklch(0.715 0.143 215.221);
|
|
||||||
--sidebar-primary-foreground: oklch(0.302 0.056 229.695);
|
|
||||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
|
||||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
@apply border-border outline-ring/50;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
|
||||||
html {
|
|
||||||
@apply font-sans;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
51
src/App.tsx
Normal file
51
src/App.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
// import reactLogo from "./assets/react.svg";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import "./App.css";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [greetMsg, setGreetMsg] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
|
||||||
|
async function greet() {
|
||||||
|
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||||
|
setGreetMsg(await invoke("greet", { name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container">
|
||||||
|
<h1>Welcome to Tauri + React</h1>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<a href="https://vite.dev" target="_blank">
|
||||||
|
<img src="/vite.svg" className="logo vite" alt="Vite logo" />
|
||||||
|
</a>
|
||||||
|
<a href="https://tauri.app" target="_blank">
|
||||||
|
<img src="/tauri.svg" className="logo tauri" alt="Tauri logo" />
|
||||||
|
</a>
|
||||||
|
<a href="https://react.dev" target="_blank">
|
||||||
|
{/* <img src={reactLogo} className="logo react" alt="React logo" /> */}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p>Click on the Tauri, Vite, and React logos to learn more.</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="row"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
greet();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="greet-input"
|
||||||
|
onChange={(e) => setName(e.currentTarget.value)}
|
||||||
|
placeholder="Enter a name..."
|
||||||
|
/>
|
||||||
|
<button type="submit">Greet</button>
|
||||||
|
</form>
|
||||||
|
<p>{greetMsg}</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
import { Slot } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "#lib/utils"
|
|
||||||
|
|
||||||
const buttonVariants = cva(
|
|
||||||
"group/button inline-flex shrink-0 items-center justify-center rounded-4xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
|
||||||
outline:
|
|
||||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-transparent dark:hover:bg-input/30",
|
|
||||||
secondary:
|
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
|
||||||
ghost:
|
|
||||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
|
||||||
destructive:
|
|
||||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default:
|
|
||||||
"h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
|
|
||||||
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
|
|
||||||
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
||||||
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
|
||||||
icon: "size-9",
|
|
||||||
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
|
|
||||||
"icon-sm": "size-8",
|
|
||||||
"icon-lg": "size-10",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function Button({
|
|
||||||
className,
|
|
||||||
variant = "default",
|
|
||||||
size = "default",
|
|
||||||
asChild = false,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"button"> &
|
|
||||||
VariantProps<typeof buttonVariants> & {
|
|
||||||
asChild?: boolean
|
|
||||||
}) {
|
|
||||||
const Comp = asChild ? Slot.Root : "button"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
data-slot="button"
|
|
||||||
data-variant={variant}
|
|
||||||
data-size={size}
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { Drawer as DrawerPrimitive } from "vaul"
|
|
||||||
|
|
||||||
import { cn } from "#lib/utils"
|
|
||||||
|
|
||||||
function Drawer({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
|
||||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
|
||||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerPortal({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
|
||||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerClose({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
|
||||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerOverlay({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
|
||||||
return (
|
|
||||||
<DrawerPrimitive.Overlay
|
|
||||||
data-slot="drawer-overlay"
|
|
||||||
className={cn(
|
|
||||||
"fixed inset-0 z-50 bg-black/30 supports-backdrop-filter:backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerContent({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<DrawerPortal data-slot="drawer-portal">
|
|
||||||
<DrawerOverlay />
|
|
||||||
<DrawerPrimitive.Content
|
|
||||||
data-slot="drawer-content"
|
|
||||||
className={cn(
|
|
||||||
"group/drawer-content fixed z-50 flex h-auto flex-col bg-transparent p-4 text-sm before:absolute before:inset-2 before:-z-10 before:rounded-4xl before:border before:border-border before:bg-popover before:shadow-xl data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className="mx-auto mt-4 hidden h-1.5 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
|
||||||
{children}
|
|
||||||
</DrawerPrimitive.Content>
|
|
||||||
</DrawerPortal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="drawer-header"
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="drawer-footer"
|
|
||||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerTitle({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
|
||||||
return (
|
|
||||||
<DrawerPrimitive.Title
|
|
||||||
data-slot="drawer-title"
|
|
||||||
className={cn(
|
|
||||||
"font-heading text-base font-medium text-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DrawerDescription({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
|
||||||
return (
|
|
||||||
<DrawerPrimitive.Description
|
|
||||||
data-slot="drawer-description"
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Drawer,
|
|
||||||
DrawerPortal,
|
|
||||||
DrawerOverlay,
|
|
||||||
DrawerTrigger,
|
|
||||||
DrawerClose,
|
|
||||||
DrawerContent,
|
|
||||||
DrawerHeader,
|
|
||||||
DrawerFooter,
|
|
||||||
DrawerTitle,
|
|
||||||
DrawerDescription,
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "#lib/utils"
|
|
||||||
|
|
||||||
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="empty"
|
|
||||||
className={cn(
|
|
||||||
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-2xl border-dashed p-12 text-center text-balance",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="empty-header"
|
|
||||||
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyMediaVariants = cva(
|
|
||||||
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-transparent",
|
|
||||||
icon: "flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted text-foreground [&_svg:not([class*='size-'])]:size-5",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function EmptyMedia({
|
|
||||||
className,
|
|
||||||
variant = "default",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="empty-icon"
|
|
||||||
data-variant={variant}
|
|
||||||
className={cn(emptyMediaVariants({ variant, className }))}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="empty-title"
|
|
||||||
className={cn(
|
|
||||||
"font-heading text-lg font-medium tracking-tight",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="empty-description"
|
|
||||||
className={cn(
|
|
||||||
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="empty-content"
|
|
||||||
className={cn(
|
|
||||||
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Empty,
|
|
||||||
EmptyHeader,
|
|
||||||
EmptyTitle,
|
|
||||||
EmptyDescription,
|
|
||||||
EmptyContent,
|
|
||||||
EmptyMedia,
|
|
||||||
}
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
import { useMemo } from "react"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "#lib/utils"
|
|
||||||
import { Label } from "#components/ui/label"
|
|
||||||
import { Separator } from "#components/ui/separator"
|
|
||||||
|
|
||||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
|
||||||
return (
|
|
||||||
<fieldset
|
|
||||||
data-slot="field-set"
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldLegend({
|
|
||||||
className,
|
|
||||||
variant = "legend",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
|
||||||
return (
|
|
||||||
<legend
|
|
||||||
data-slot="field-legend"
|
|
||||||
data-variant={variant}
|
|
||||||
className={cn(
|
|
||||||
"mb-3 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="field-group"
|
|
||||||
className={cn(
|
|
||||||
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldVariants = cva(
|
|
||||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
orientation: {
|
|
||||||
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
|
||||||
horizontal:
|
|
||||||
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
|
||||||
responsive:
|
|
||||||
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
orientation: "vertical",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function Field({
|
|
||||||
className,
|
|
||||||
orientation = "vertical",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="group"
|
|
||||||
data-slot="field"
|
|
||||||
data-orientation={orientation}
|
|
||||||
className={cn(fieldVariants({ orientation }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="field-content"
|
|
||||||
className={cn(
|
|
||||||
"group/field-content flex flex-1 flex-col gap-1 leading-snug",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldLabel({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof Label>) {
|
|
||||||
return (
|
|
||||||
<Label
|
|
||||||
data-slot="field-label"
|
|
||||||
className={cn(
|
|
||||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:bg-input/30 has-[>[data-slot=field]]:rounded-2xl has-[>[data-slot=field]]:border *:data-[slot=field]:p-4",
|
|
||||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="field-label"
|
|
||||||
className={cn(
|
|
||||||
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
|
||||||
return (
|
|
||||||
<p
|
|
||||||
data-slot="field-description"
|
|
||||||
className={cn(
|
|
||||||
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
|
||||||
"last:mt-0 nth-last-2:-mt-1",
|
|
||||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldSeparator({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & {
|
|
||||||
children?: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="field-separator"
|
|
||||||
data-content={!!children}
|
|
||||||
className={cn(
|
|
||||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Separator className="absolute inset-0 top-1/2" />
|
|
||||||
{children && (
|
|
||||||
<span
|
|
||||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
|
||||||
data-slot="field-separator-content"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldError({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
errors,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & {
|
|
||||||
errors?: Array<{ message?: string } | undefined>
|
|
||||||
}) {
|
|
||||||
const content = useMemo(() => {
|
|
||||||
if (children) {
|
|
||||||
return children
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!errors?.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueErrors = [
|
|
||||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
|
||||||
]
|
|
||||||
|
|
||||||
if (uniqueErrors?.length == 1) {
|
|
||||||
return uniqueErrors[0]?.message
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
|
||||||
{uniqueErrors.map(
|
|
||||||
(error, index) =>
|
|
||||||
error?.message && <li key={index}>{error.message}</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
}, [children, errors])
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
data-slot="field-error"
|
|
||||||
className={cn("text-sm font-normal text-destructive", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Field,
|
|
||||||
FieldLabel,
|
|
||||||
FieldDescription,
|
|
||||||
FieldError,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLegend,
|
|
||||||
FieldSeparator,
|
|
||||||
FieldSet,
|
|
||||||
FieldContent,
|
|
||||||
FieldTitle,
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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 }
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "#lib/utils"
|
|
||||||
|
|
||||||
function ScrollArea({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<ScrollAreaPrimitive.Root
|
|
||||||
data-slot="scroll-area"
|
|
||||||
className={cn("relative", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ScrollAreaPrimitive.Viewport
|
|
||||||
data-slot="scroll-area-viewport"
|
|
||||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ScrollAreaPrimitive.Viewport>
|
|
||||||
<ScrollBar />
|
|
||||||
<ScrollAreaPrimitive.Corner />
|
|
||||||
</ScrollAreaPrimitive.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScrollBar({
|
|
||||||
className,
|
|
||||||
orientation = "vertical",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
|
||||||
return (
|
|
||||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
|
||||||
data-slot="scroll-area-scrollbar"
|
|
||||||
data-orientation={orientation}
|
|
||||||
orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
|
||||||
data-slot="scroll-area-thumb"
|
|
||||||
className="relative flex-1 rounded-full bg-border"
|
|
||||||
/>
|
|
||||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { ScrollArea, ScrollBar }
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "#lib/utils"
|
|
||||||
|
|
||||||
function Separator({
|
|
||||||
className,
|
|
||||||
orientation = "horizontal",
|
|
||||||
decorative = true,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<SeparatorPrimitive.Root
|
|
||||||
data-slot="separator"
|
|
||||||
decorative={decorative}
|
|
||||||
orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Separator }
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { cn } from "#lib/utils"
|
|
||||||
|
|
||||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="skeleton"
|
|
||||||
className={cn("animate-pulse rounded-2xl bg-muted", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Skeleton }
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { Home, Bell, FileText, User, Menu, CirclePlus } from 'lucide-react';
|
|
||||||
import { useMemo, useState } from 'react';
|
|
||||||
import { Button } from './components/ui/button';
|
|
||||||
import { useAppStore } from './store';
|
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
|
||||||
|
|
||||||
function AccountDisplay({ info }: { info: { name: string; balance: string } | null | undefined }) {
|
|
||||||
const name = useMemo(() => info?.name ?? 'No account selected', [info]);
|
|
||||||
const balance = useMemo(() => info?.balance ?? '--', [info]);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p className="text-sm">{name}</p>
|
|
||||||
<p className="text-xs font-bold">${balance}</p>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TopNavigation() {
|
|
||||||
const { accounts, selectedAccountId } = useAppStore(useShallow((state) => ({ accounts: state.accounts, selectedAccountId: state.selectedAccountId })));
|
|
||||||
|
|
||||||
const selectedAccount = accounts.find((account) => account.id === selectedAccountId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between shadow-sm">
|
|
||||||
<div className="flex-1">
|
|
||||||
<AccountDisplay info={selectedAccount ? { name: selectedAccount.name, balance: selectedAccount.balance } : null} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type BottomNavigationButtonProps = {
|
|
||||||
icon: React.ComponentType<{ className?: string; fill?: string }>;
|
|
||||||
label?: string;
|
|
||||||
isSelected?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function BottomNavigationButton({ icon: Icon, label, isSelected }: BottomNavigationButtonProps) {
|
|
||||||
return (
|
|
||||||
<Button variant="ghost" size="icon" className="text-gray-600 hover:text-blue-500 flex flex-col items-center">
|
|
||||||
<Icon className={`h-8 w-8`} fill={isSelected ? 'blue-500' : 'none'} />
|
|
||||||
{label && <span className="text-xs mt-1">{label}</span>}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BottomNavigation() {
|
|
||||||
return (
|
|
||||||
<div className="bg-white border-t border-gray-200 px-6 pb-4 pt-2 flex items-center justify-around">
|
|
||||||
<BottomNavigationButton icon={Home} />
|
|
||||||
<BottomNavigationButton icon={Bell} />
|
|
||||||
<div className="pb-2">
|
|
||||||
<Button variant="ghost" size="icon-lg" asChild className="text-gray-600 hover:text-blue-500">
|
|
||||||
<CirclePlus className="h-12 w-12" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<BottomNavigationButton icon={FileText} />
|
|
||||||
<BottomNavigationButton icon={User} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppLayout({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-screen ">
|
|
||||||
{/* Top Bar */}
|
|
||||||
<TopNavigation />
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="flex-1 overflow-auto">{children}</div>
|
|
||||||
|
|
||||||
{/* Bottom Navigation */}
|
|
||||||
<BottomNavigation />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export type AccountType = 'Asset' | 'Liability' | 'Unknown';
|
|
||||||
export type Account = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
accountType: AccountType;
|
|
||||||
balance: string;
|
|
||||||
accountCategory: string;
|
|
||||||
//
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
};
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
|
||||||
import { twMerge } from "tailwind-merge"
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs))
|
|
||||||
}
|
|
||||||
9
src/main.tsx
Normal file
9
src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
71
src/root.tsx
71
src/root.tsx
@@ -1,71 +0,0 @@
|
|||||||
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';
|
|
||||||
|
|
||||||
import type { Route } from './+types/root';
|
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
|
||||||
import './App.css';
|
|
||||||
import { AppLayout } from './layout';
|
|
||||||
|
|
||||||
export const links: Route.LinksFunction = () => [
|
|
||||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
|
||||||
{
|
|
||||||
rel: 'preconnect',
|
|
||||||
href: 'https://fonts.gstatic.com',
|
|
||||||
crossOrigin: 'anonymous',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rel: 'stylesheet',
|
|
||||||
href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charSet="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<Meta />
|
|
||||||
<Links />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
{children}
|
|
||||||
<ScrollRestoration />
|
|
||||||
<Scripts />
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
return (
|
|
||||||
<AppLayout>
|
|
||||||
<Outlet />
|
|
||||||
</AppLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
|
||||||
let message = 'Oops!';
|
|
||||||
let details = 'An unexpected error occurred.';
|
|
||||||
let stack: string | undefined;
|
|
||||||
|
|
||||||
if (isRouteErrorResponse(error)) {
|
|
||||||
message = error.status === 404 ? '404' : 'Error';
|
|
||||||
details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details;
|
|
||||||
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
|
||||||
details = error.message;
|
|
||||||
stack = error.stack;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="pt-16 p-4 container mx-auto">
|
|
||||||
<h1>{message}</h1>
|
|
||||||
<p>{details}</p>
|
|
||||||
{stack && (
|
|
||||||
<pre className="w-full p-4 overflow-x-auto">
|
|
||||||
<code>{stack}</code>
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { type RouteConfig, index } from '@react-router/dev/routes';
|
|
||||||
|
|
||||||
export default [
|
|
||||||
//
|
|
||||||
index('routes/home.tsx'),
|
|
||||||
] satisfies RouteConfig;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { Button } from '../components/ui/button';
|
|
||||||
import type { Route } from './+types/home';
|
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
|
||||||
return [{ title: 'New React Router App' }, { name: 'description', content: 'Welcome to React Router!' }];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('Home page loaded');
|
|
||||||
}, []);
|
|
||||||
return <Button>Go to the about page</Button>;
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { StateCreator } from 'zustand';
|
|
||||||
import { Account as TauriAccount } from '../lib/tauri/types/account';
|
|
||||||
|
|
||||||
export type Account = TauriAccount;
|
|
||||||
|
|
||||||
export interface AccountsSlice {
|
|
||||||
accounts: Account[];
|
|
||||||
selectedAccountId?: string;
|
|
||||||
//
|
|
||||||
selectAccount: (accountId: string) => void;
|
|
||||||
addAccount: (account: Account) => void;
|
|
||||||
removeAccount: (accountId: string) => void;
|
|
||||||
updateAccount: (account: Account) => void;
|
|
||||||
reloadAccounts: (accounts?: Account[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createAccountsSlice: StateCreator<AccountsSlice, [], [], AccountsSlice> = (set) => ({
|
|
||||||
accounts: [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
name: 'Checking Account',
|
|
||||||
accountType: 'Asset',
|
|
||||||
balance: '5000.00',
|
|
||||||
accountCategory: 'Bank',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
selectedAccountId: '1',
|
|
||||||
selectAccount: (accountId) =>
|
|
||||||
set(() => {
|
|
||||||
return { selectedAccountId: accountId };
|
|
||||||
}),
|
|
||||||
addAccount: (account) =>
|
|
||||||
set((state) => {
|
|
||||||
return { accounts: [...state.accounts, account] };
|
|
||||||
}),
|
|
||||||
removeAccount: (accountId) =>
|
|
||||||
set((state) => {
|
|
||||||
return { accounts: state.accounts.filter((account) => account.id !== accountId) };
|
|
||||||
}),
|
|
||||||
updateAccount: (updatedAccount) =>
|
|
||||||
set((state) => {
|
|
||||||
return { accounts: state.accounts.map((account) => (account.id === updatedAccount.id ? updatedAccount : account)) };
|
|
||||||
}),
|
|
||||||
reloadAccounts: (accounts?: Account[]) =>
|
|
||||||
set((_state) => {
|
|
||||||
return { accounts: accounts ?? [] };
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
import { type AccountsSlice, createAccountsSlice } from './accounts';
|
|
||||||
|
|
||||||
export interface AppStore extends AccountsSlice {}
|
|
||||||
|
|
||||||
export const useAppStore = create<AppStore>()((...a) => ({
|
|
||||||
...createAccountsSlice(...a),
|
|
||||||
}));
|
|
||||||
@@ -1,26 +1,17 @@
|
|||||||
import { reactRouter } from '@react-router/dev/vite';
|
import { defineConfig } from "vite";
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig } from 'vite';
|
|
||||||
// import react from '@vitejs/plugin-react';
|
|
||||||
|
|
||||||
// @ts-expect-error process is a nodejs global
|
// @ts-expect-error process is a nodejs global
|
||||||
const host = process.env.TAURI_DEV_HOST;
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig(async () => ({
|
export default defineConfig(async () => ({
|
||||||
plugins: [
|
plugins: [react()],
|
||||||
// react(),
|
|
||||||
tailwindcss(),
|
|
||||||
reactRouter(),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
//
|
//
|
||||||
// 1. prevent Vite from obscuring rust errors
|
// 1. prevent Vite from obscuring rust errors
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
resolve: {
|
|
||||||
tsconfigPaths: true,
|
|
||||||
},
|
|
||||||
// 2. tauri expects a fixed port, fail if that port is not available
|
// 2. tauri expects a fixed port, fail if that port is not available
|
||||||
server: {
|
server: {
|
||||||
port: 1420,
|
port: 1420,
|
||||||
@@ -28,14 +19,14 @@ export default defineConfig(async () => ({
|
|||||||
host: host || false,
|
host: host || false,
|
||||||
hmr: host
|
hmr: host
|
||||||
? {
|
? {
|
||||||
protocol: 'ws',
|
protocol: "ws",
|
||||||
host,
|
host,
|
||||||
port: 1421,
|
port: 1421,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
watch: {
|
watch: {
|
||||||
// 3. tell Vite to ignore watching `src-tauri`
|
// 3. tell Vite to ignore watching `src-tauri`
|
||||||
ignored: ['**/src-tauri/**'],
|
ignored: ["**/src-tauri/**"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user