mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-29 14:29:06 +00:00
Compare commits
10 Commits
2025.9.16
...
59e5bc13d6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59e5bc13d6 | ||
|
|
b63d30c7a5 | ||
|
|
ecdf9f8ba3 | ||
|
|
dd24a0e0d3 | ||
|
|
ff9bccc0e9 | ||
|
|
83bb3fcef2 | ||
|
|
fbb9e33279 | ||
|
|
8228974c0c | ||
|
|
848eccb35b | ||
|
|
25747d7d37 |
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -143,19 +143,19 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install git-cliff
|
- name: Install git-cliff
|
||||||
run: |
|
run: |
|
||||||
wget -qO- https://github.com/orhun/git-cliff/releases/latest/download/git-cliff-2.10.0-x86_64-unknown-linux-gnu.tar.gz | tar xz
|
wget -qO- https://github.com/orhun/git-cliff/releases/latest/download/git-cliff-2.10.0-x86_64-unknown-linux-gnu.tar.gz | tar xz
|
||||||
sudo mv git-cliff-*/git-cliff /usr/local/bin/
|
sudo mv git-cliff-*/git-cliff /usr/local/bin/
|
||||||
|
|
||||||
- name: Generate release notes
|
- name: Generate release notes
|
||||||
id: release_notes
|
id: release_notes
|
||||||
run: |
|
run: |
|
||||||
echo "notes<<EOF" >> $GITHUB_OUTPUT
|
echo "notes<<EOF" >> $GITHUB_OUTPUT
|
||||||
git-cliff --current >> $GITHUB_OUTPUT
|
git-cliff --current >> $GITHUB_OUTPUT
|
||||||
echo "EOF" >> $GITHUB_OUTPUT
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
|
|||||||
13
AGENTS.md
13
AGENTS.md
@@ -81,6 +81,7 @@ The command outputs instructions for setting the required environment variable t
|
|||||||
- **Naming**: PascalCase for components, camelCase for variables/functions
|
- **Naming**: PascalCase for components, camelCase for variables/functions
|
||||||
- **Types**: Use `import type` for type-only imports, define interfaces/types
|
- **Types**: Use `import type` for type-only imports, define interfaces/types
|
||||||
- **Styling**: Tailwind CSS with `clsx` utility for conditional classes
|
- **Styling**: Tailwind CSS with `clsx` utility for conditional classes
|
||||||
|
- **UI Components**: shadcn/ui components for consistent design system
|
||||||
- **Icons**: lucide-react with consistent naming
|
- **Icons**: lucide-react with consistent naming
|
||||||
- **Data fetching**: @tanstack/react-query with proper error handling
|
- **Data fetching**: @tanstack/react-query with proper error handling
|
||||||
- **Components**: Functional components with hooks, proper TypeScript typing
|
- **Components**: Functional components with hooks, proper TypeScript typing
|
||||||
@@ -94,7 +95,7 @@ The command outputs instructions for setting the required environment variable t
|
|||||||
- **Routes**: `frontend/src/routes/` - TanStack Router file-based routing
|
- **Routes**: `frontend/src/routes/` - TanStack Router file-based routing
|
||||||
|
|
||||||
### Key Components Location
|
### Key Components Location
|
||||||
- **UI Components**: `frontend/src/components/ui/` - Reusable UI primitives
|
- **UI Components**: `frontend/src/components/ui/` - shadcn/ui components and reusable UI primitives
|
||||||
- **Feature Components**: `frontend/src/components/` - Main app components
|
- **Feature Components**: `frontend/src/components/` - Main app components
|
||||||
- **Pages**: `frontend/src/routes/` - Route components (index.tsx, transactions.tsx, etc.)
|
- **Pages**: `frontend/src/routes/` - Route components (index.tsx, transactions.tsx, etc.)
|
||||||
- **Hooks**: `frontend/src/hooks/` - Custom React hooks
|
- **Hooks**: `frontend/src/hooks/` - Custom React hooks
|
||||||
@@ -121,6 +122,16 @@ The command outputs instructions for setting the required environment variable t
|
|||||||
- Avoid including specific numbers, counts, or data-dependent information that may become outdated
|
- Avoid including specific numbers, counts, or data-dependent information that may become outdated
|
||||||
- **Security**: Never log sensitive data, use environment variables for secrets
|
- **Security**: Never log sensitive data, use environment variables for secrets
|
||||||
|
|
||||||
|
## AI Development Support
|
||||||
|
|
||||||
|
### shadcn/ui Integration
|
||||||
|
This project uses shadcn/ui for consistent UI components. The MCP server is configured for AI agents to:
|
||||||
|
- Search and browse available shadcn/ui components
|
||||||
|
- View component implementation details and examples
|
||||||
|
- Generate proper installation commands for new components
|
||||||
|
|
||||||
|
Use the shadcn MCP tools when working with UI components to ensure consistency with the existing design system.
|
||||||
|
|
||||||
## Contributing Guidelines
|
## Contributing Guidelines
|
||||||
|
|
||||||
This repository follows conventional changelog practices. Refer to `CONTRIBUTING.md` for detailed contribution guidelines including:
|
This repository follows conventional changelog practices. Refer to `CONTRIBUTING.md` for detailed contribution guidelines including:
|
||||||
|
|||||||
52
CHANGELOG.md
52
CHANGELOG.md
@@ -1,4 +1,56 @@
|
|||||||
|
|
||||||
|
## 2025.9.18 (2025/09/19)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Add instructions for shadcn/ui. ([83bb3fce](https://github.com/elisiariocouto/leggen/commit/83bb3fcef20d21a210bc53ce77aa533d37771668))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **frontend:** Transform layout to use shadcn dashboard-01 with iOS PWA safe area support. ([fbb9e332](https://github.com/elisiariocouto/leggen/commit/fbb9e33279028a6a7ccf46c3696a012ec16a9ca7))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.18 (2025/09/19)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Add instructions for shadcn/ui. ([83bb3fce](https://github.com/elisiariocouto/leggen/commit/83bb3fcef20d21a210bc53ce77aa533d37771668))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **frontend:** Transform layout to use shadcn dashboard-01 with iOS PWA safe area support. ([fbb9e332](https://github.com/elisiariocouto/leggen/commit/fbb9e33279028a6a7ccf46c3696a012ec16a9ca7))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.17 (2025/09/18)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **api:** Prevent duplicate notifications for existing transactions during sync. ([25747d7d](https://github.com/elisiariocouto/leggen/commit/25747d7d372e291090764a6814f9d8d0b76aea3b))
|
||||||
|
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- Format files. ([848eccb3](https://github.com/elisiariocouto/leggen/commit/848eccb35b910c8121d15611547dca8da0b12756))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.17 (2025/09/18)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **api:** Prevent duplicate notifications for existing transactions during sync. ([25747d7d](https://github.com/elisiariocouto/leggen/commit/25747d7d372e291090764a6814f9d8d0b76aea3b))
|
||||||
|
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- Format files. ([848eccb3](https://github.com/elisiariocouto/leggen/commit/848eccb35b910c8121d15611547dca8da0b12756))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 2025.9.16 (2025/09/18)
|
## 2025.9.16 (2025/09/18)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
# Contributing
|
# Contributing
|
||||||
|
|
||||||
Install Poetry and run `poetry install` to install dependencies. Then run `poetry shell` to activate the virtual environment.
|
This project uses **uv** for Python dependency management and **shadcn/ui** for frontend components.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
Install uv and run `uv sync` to install dependencies.
|
||||||
|
|
||||||
Run `pre-commit install` to install the pre-commit hooks.
|
Run `pre-commit install` to install the pre-commit hooks.
|
||||||
|
|
||||||
|
## Frontend Development
|
||||||
|
The frontend uses shadcn/ui components for consistent design. When adding new UI components:
|
||||||
|
- Check if a shadcn/ui component exists for your use case
|
||||||
|
- Follow the existing component patterns in `frontend/src/components/ui/`
|
||||||
|
- Use Tailwind CSS classes for styling
|
||||||
|
- Ensure components are accessible and follow the design system
|
||||||
|
|
||||||
## Commit messages
|
## Commit messages
|
||||||
|
|
||||||
type(scope/[subscope]): Title starting with uppercase and sentence ending with period.
|
type(scope/[subscope]): Title starting with uppercase and sentence ending with period.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Having your bank data accessible through both CLI and REST API gives you the pow
|
|||||||
- [React](https://reactjs.org/): Modern web interface with TypeScript
|
- [React](https://reactjs.org/): Modern web interface with TypeScript
|
||||||
- [Vite](https://vitejs.dev/): Fast build tool and development server
|
- [Vite](https://vitejs.dev/): Fast build tool and development server
|
||||||
- [Tailwind CSS](https://tailwindcss.com/): Utility-first CSS framework
|
- [Tailwind CSS](https://tailwindcss.com/): Utility-first CSS framework
|
||||||
|
- [shadcn/ui](https://ui.shadcn.com/): Modern component system built on Radix UI
|
||||||
- [TanStack Query](https://tanstack.com/query): Powerful data synchronization for React
|
- [TanStack Query](https://tanstack.com/query): Powerful data synchronization for React
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|||||||
722
frontend/package-lock.json
generated
722
frontend/package-lock.json
generated
@@ -8,11 +8,25 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@tabler/icons-react": "^3.35.0",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tanstack/react-query": "^5.87.1",
|
"@tanstack/react-query": "^5.87.1",
|
||||||
"@tanstack/react-router": "^1.131.36",
|
"@tanstack/react-router": "^1.131.36",
|
||||||
@@ -25,15 +39,18 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-day-picker": "^9.10.0",
|
"react-day-picker": "^9.10.0",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"recharts": "^3.2.0",
|
"recharts": "^2.15.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.33.0",
|
"@eslint/js": "^9.33.0",
|
||||||
@@ -1637,7 +1654,6 @@
|
|||||||
"version": "7.28.4",
|
"version": "7.28.4",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||||
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -1721,6 +1737,73 @@
|
|||||||
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/modifiers": {
|
||||||
|
"version": "9.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@dotenvx/dotenvx": {
|
"node_modules/@dotenvx/dotenvx": {
|
||||||
"version": "1.50.1",
|
"version": "1.50.1",
|
||||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.50.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.50.1.tgz",
|
||||||
@@ -3492,6 +3575,63 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-avatar": {
|
||||||
|
"version": "1.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz",
|
||||||
|
"integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-is-hydrated": "0.1.0",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-checkbox": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-collection": {
|
"node_modules/@radix-ui/react-collection": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||||
@@ -3626,6 +3766,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-dropdown-menu": {
|
||||||
|
"version": "2.1.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
|
||||||
|
"integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-menu": "2.1.16",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-focus-guards": {
|
"node_modules/@radix-ui/react-focus-guards": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
||||||
@@ -3684,6 +3853,69 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-label": {
|
||||||
|
"version": "2.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
|
||||||
|
"integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-menu": {
|
||||||
|
"version": "2.1.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
|
||||||
|
"integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-collection": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||||
|
"@radix-ui/react-focus-guards": "1.1.3",
|
||||||
|
"@radix-ui/react-focus-scope": "1.1.7",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-popper": "1.2.8",
|
||||||
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-roving-focus": "1.1.11",
|
||||||
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"aria-hidden": "^1.2.4",
|
||||||
|
"react-remove-scroll": "^2.6.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-popover": {
|
"node_modules/@radix-ui/react-popover": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
|
||||||
@@ -3848,6 +4080,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-roving-focus": {
|
||||||
|
"version": "1.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
|
||||||
|
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-collection": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-select": {
|
"node_modules/@radix-ui/react-select": {
|
||||||
"version": "2.2.6",
|
"version": "2.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
||||||
@@ -3891,6 +4154,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-separator": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-slot": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
@@ -3909,6 +4195,124 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-tabs": {
|
||||||
|
"version": "1.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
||||||
|
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-roving-focus": "1.1.11",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-toggle": {
|
||||||
|
"version": "1.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
|
||||||
|
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-toggle-group": {
|
||||||
|
"version": "1.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz",
|
||||||
|
"integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-roving-focus": "1.1.11",
|
||||||
|
"@radix-ui/react-toggle": "1.1.10",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-tooltip": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-popper": "1.2.8",
|
||||||
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-visually-hidden": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
@@ -3979,6 +4383,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-is-hydrated": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"use-sync-external-store": "^1.5.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-use-layout-effect": {
|
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||||
@@ -4074,32 +4496,6 @@
|
|||||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@reduxjs/toolkit": {
|
|
||||||
"version": "2.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz",
|
|
||||||
"integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@standard-schema/spec": "^1.0.0",
|
|
||||||
"@standard-schema/utils": "^0.3.0",
|
|
||||||
"immer": "^10.0.3",
|
|
||||||
"redux": "^5.0.1",
|
|
||||||
"redux-thunk": "^3.1.0",
|
|
||||||
"reselect": "^5.1.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
|
||||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react-redux": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.35",
|
"version": "1.0.0-beta.35",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz",
|
||||||
@@ -4505,18 +4901,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@standard-schema/utils": {
|
|
||||||
"version": "0.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
|
||||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@surma/rollup-plugin-off-main-thread": {
|
"node_modules/@surma/rollup-plugin-off-main-thread": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
|
||||||
@@ -4530,6 +4914,32 @@
|
|||||||
"string.prototype.matchall": "^4.0.6"
|
"string.prototype.matchall": "^4.0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tabler/icons": {
|
||||||
|
"version": "3.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.35.0.tgz",
|
||||||
|
"integrity": "sha512-yYXe+gJ56xlZFiXwV9zVoe3FWCGuZ/D7/G4ZIlDtGxSx5CGQK110wrnT29gUj52kEZoxqF7oURTk97GQxELOFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/codecalm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tabler/icons-react": {
|
||||||
|
"version": "3.35.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.35.0.tgz",
|
||||||
|
"integrity": "sha512-XG7t2DYf3DyHT5jxFNp5xyLVbL4hMJYJhiSdHADzAjLRYfL7AnjlRfiHDHeXxkb2N103rEIvTsBRazxXtAUz2g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tabler/icons": "3.35.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/codecalm"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">= 16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/forms": {
|
"node_modules/@tailwindcss/forms": {
|
||||||
"version": "0.5.10",
|
"version": "0.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz",
|
||||||
@@ -5036,12 +5446,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/use-sync-external-store": {
|
|
||||||
"version": "0.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
|
||||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.44.0",
|
"version": "8.44.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.0.tgz",
|
||||||
@@ -6825,7 +7229,6 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/d3-array": {
|
"node_modules/d3-array": {
|
||||||
@@ -7212,6 +7615,16 @@
|
|||||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-helpers": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.8.7",
|
||||||
|
"csstype": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.2.2",
|
"version": "17.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz",
|
||||||
@@ -7461,16 +7874,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-toolkit": {
|
|
||||||
"version": "1.39.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz",
|
|
||||||
"integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"workspaces": [
|
|
||||||
"docs",
|
|
||||||
"benchmarks"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.10",
|
"version": "0.25.10",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
|
||||||
@@ -7763,9 +8166,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eventemitter3": {
|
"node_modules/eventemitter3": {
|
||||||
"version": "5.0.1",
|
"version": "4.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/eventsource": {
|
"node_modules/eventsource": {
|
||||||
@@ -7907,6 +8310,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-equals": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
@@ -8773,16 +9185,6 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/immer": {
|
|
||||||
"version": "10.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
|
|
||||||
"integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/immer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -9624,7 +10026,6 @@
|
|||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.debounce": {
|
"node_modules/lodash.debounce": {
|
||||||
@@ -9691,6 +10092,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/loose-envify": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"loose-envify": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -9973,6 +10386,16 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-themes": {
|
||||||
|
"version": "0.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
||||||
|
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-domexception": {
|
"node_modules/node-domexception": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||||
@@ -10712,6 +11135,23 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prop-types": {
|
||||||
|
"version": "15.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.4.0",
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"react-is": "^16.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prop-types/node_modules/react-is": {
|
||||||
|
"version": "16.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
@@ -10891,34 +11331,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "19.1.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||||
"integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==",
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"node_modules/react-redux": {
|
|
||||||
"version": "9.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
|
||||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/use-sync-external-store": "^0.0.6",
|
|
||||||
"use-sync-external-store": "^1.4.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "^18.2.25 || ^19",
|
|
||||||
"react": "^18.0 || ^19",
|
|
||||||
"redux": "^5.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"redux": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
@@ -10977,6 +11393,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-smooth": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-equals": "^5.0.1",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"react-transition-group": "^4.4.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-style-singleton": {
|
"node_modules/react-style-singleton": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||||
@@ -10999,6 +11430,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-transition-group": {
|
||||||
|
"version": "4.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||||
|
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.5.5",
|
||||||
|
"dom-helpers": "^5.0.1",
|
||||||
|
"loose-envify": "^1.4.0",
|
||||||
|
"prop-types": "^15.6.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.6.0",
|
||||||
|
"react-dom": ">=16.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@@ -11046,45 +11493,35 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/recharts": {
|
"node_modules/recharts": {
|
||||||
"version": "3.2.1",
|
"version": "2.15.4",
|
||||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
||||||
"integrity": "sha512-0JKwHRiFZdmLq/6nmilxEZl3pqb4T+aKkOkOi/ZISRZwfBhVMgInxzlYU9D4KnCH3KINScLy68m/OvMXoYGZUw==",
|
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
"clsx": "^2.0.0",
|
||||||
"clsx": "^2.1.1",
|
"eventemitter3": "^4.0.1",
|
||||||
"decimal.js-light": "^2.5.1",
|
"lodash": "^4.17.21",
|
||||||
"es-toolkit": "^1.39.3",
|
"react-is": "^18.3.1",
|
||||||
"eventemitter3": "^5.0.1",
|
"react-smooth": "^4.0.4",
|
||||||
"immer": "^10.1.1",
|
"recharts-scale": "^0.4.4",
|
||||||
"react-redux": "8.x.x || 9.x.x",
|
"tiny-invariant": "^1.3.1",
|
||||||
"reselect": "5.1.1",
|
"victory-vendor": "^36.6.8"
|
||||||
"tiny-invariant": "^1.3.3",
|
|
||||||
"use-sync-external-store": "^1.2.2",
|
|
||||||
"victory-vendor": "^37.0.2"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=14"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/redux": {
|
"node_modules/recharts-scale": {
|
||||||
"version": "5.0.1",
|
"version": "0.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/redux-thunk": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"dependencies": {
|
||||||
"redux": "^5.0.0"
|
"decimal.js-light": "^2.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
@@ -11221,12 +11658,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/reselect": {
|
|
||||||
"version": "5.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
|
||||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.10",
|
"version": "1.22.10",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||||
@@ -13136,10 +13567,23 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vaul": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/victory-vendor": {
|
"node_modules/victory-vendor": {
|
||||||
"version": "37.3.6",
|
"version": "36.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
|
||||||
"license": "MIT AND ISC",
|
"license": "MIT AND ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/d3-array": "^3.0.3",
|
"@types/d3-array": "^3.0.3",
|
||||||
|
|||||||
@@ -10,11 +10,25 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@tabler/icons-react": "^3.35.0",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tanstack/react-query": "^5.87.1",
|
"@tanstack/react-query": "^5.87.1",
|
||||||
"@tanstack/react-router": "^1.131.36",
|
"@tanstack/react-router": "^1.131.36",
|
||||||
@@ -27,15 +41,18 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-day-picker": "^9.10.0",
|
"react-day-picker": "^9.10.0",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"recharts": "^3.2.0",
|
"recharts": "^2.15.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.33.0",
|
"@eslint/js": "^9.33.0",
|
||||||
|
|||||||
@@ -6,4 +6,4 @@
|
|||||||
<TileColor>#3B82F6</TileColor>
|
<TileColor>#3B82F6</TileColor>
|
||||||
</tile>
|
</tile>
|
||||||
</msapplication>
|
</msapplication>
|
||||||
</browserconfig>
|
</browserconfig>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
|
|
||||||
Sitemap: /sitemap.xml
|
Sitemap: /sitemap.xml
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"preset": "minimal-2023",
|
"preset": "minimal-2023",
|
||||||
"images": ["public/favicon.svg"]
|
"images": ["public/favicon.svg"]
|
||||||
}
|
}
|
||||||
|
|||||||
171
frontend/src/components/AppSidebar.tsx
Normal file
171
frontend/src/components/AppSidebar.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Link, useLocation } from "@tanstack/react-router";
|
||||||
|
import {
|
||||||
|
List,
|
||||||
|
BarChart3,
|
||||||
|
Activity,
|
||||||
|
Settings,
|
||||||
|
Building2,
|
||||||
|
TrendingUp,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Logo } from "./ui/logo";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { apiClient } from "../lib/api";
|
||||||
|
import { formatCurrency } from "../lib/utils";
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { Account } from "../types/api";
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
useSidebar,
|
||||||
|
} from "./ui/sidebar";
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: "Overview", icon: List, to: "/" },
|
||||||
|
{ name: "Analytics", icon: BarChart3, to: "/analytics" },
|
||||||
|
{ name: "System Status", icon: Activity, to: "/notifications" },
|
||||||
|
{ name: "Settings", icon: Settings, to: "/settings" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
|
const location = useLocation();
|
||||||
|
const [accountsExpanded, setAccountsExpanded] = useState(false);
|
||||||
|
const { isMobile, setOpenMobile } = useSidebar();
|
||||||
|
|
||||||
|
const { data: accounts } = useQuery<Account[]>({
|
||||||
|
queryKey: ["accounts"],
|
||||||
|
queryFn: apiClient.getAccounts,
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalBalance =
|
||||||
|
accounts?.reduce((sum, account) => {
|
||||||
|
const primaryBalance = account.balances?.[0]?.amount || 0;
|
||||||
|
return sum + primaryBalance;
|
||||||
|
}, 0) || 0;
|
||||||
|
|
||||||
|
// Handler to close mobile sidebar when navigation item is clicked
|
||||||
|
const handleNavigationClick = () => {
|
||||||
|
if (isMobile) {
|
||||||
|
setOpenMobile(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar collapsible="icon" className="pt-safe-top pl-safe-left" {...props}>
|
||||||
|
<SidebarHeader>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
className="data-[slot=sidebar-menu-button]:!p-1.5"
|
||||||
|
>
|
||||||
|
<Link to="/" className="flex items-center space-x-2" onClick={handleNavigationClick}>
|
||||||
|
<Logo size={24} />
|
||||||
|
<span className="text-base font-semibold">Leggen</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarHeader>
|
||||||
|
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{navigation.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.to}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
tooltip={item.name}
|
||||||
|
isActive={location.pathname === item.to}
|
||||||
|
>
|
||||||
|
<Link to={item.to} onClick={handleNavigationClick}>
|
||||||
|
<item.icon className="h-5 w-5" />
|
||||||
|
<span>{item.name}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</SidebarContent>
|
||||||
|
|
||||||
|
<SidebarFooter>
|
||||||
|
{/* Account Summary Section */}
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Account Summary</SidebarGroupLabel>
|
||||||
|
<div className="bg-muted rounded-lg p-1">
|
||||||
|
{/* Collapsible Header */}
|
||||||
|
<button
|
||||||
|
onClick={() => setAccountsExpanded(!accountsExpanded)}
|
||||||
|
className="w-full p-3 flex items-center justify-between hover:bg-muted/80 transition-colors rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
Total Balance
|
||||||
|
</span>
|
||||||
|
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||||
|
</div>
|
||||||
|
{accountsExpanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="px-3 pb-2">
|
||||||
|
<p className="text-xl font-bold text-foreground">
|
||||||
|
{formatCurrency(totalBalance)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{accounts?.length || 0} accounts
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Account Details */}
|
||||||
|
{accountsExpanded && accounts && accounts.length > 0 && (
|
||||||
|
<div className="border-t border-border/50 max-h-48 overflow-y-auto">
|
||||||
|
{accounts.map((account) => {
|
||||||
|
const primaryBalance = account.balances?.[0]?.amount || 0;
|
||||||
|
const currency = account.balances?.[0]?.currency || account.currency || "EUR";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={account.id}
|
||||||
|
className="p-2 border-b border-border/30 last:border-b-0 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start space-x-2">
|
||||||
|
<div className="flex-shrink-0 p-1 bg-background rounded">
|
||||||
|
<Building2 className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-medium text-foreground truncate">
|
||||||
|
{account.display_name || account.name || "Unnamed Account"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-semibold text-foreground">
|
||||||
|
{formatCurrency(primaryBalance, currency)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SidebarGroup>
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
Settings,
|
Settings,
|
||||||
TestTube,
|
TestTube,
|
||||||
|
Activity,
|
||||||
|
Clock,
|
||||||
|
TrendingUp,
|
||||||
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiClient } from "../lib/api";
|
import { apiClient } from "../lib/api";
|
||||||
import NotificationsSkeleton from "./NotificationsSkeleton";
|
import NotificationsSkeleton from "./NotificationsSkeleton";
|
||||||
@@ -32,7 +36,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "./ui/select";
|
} from "./ui/select";
|
||||||
import type { NotificationSettings, NotificationService } from "../types/api";
|
import type { NotificationSettings, NotificationService, SyncOperationsResponse } from "../types/api";
|
||||||
|
|
||||||
export default function Notifications() {
|
export default function Notifications() {
|
||||||
const [testService, setTestService] = useState("");
|
const [testService, setTestService] = useState("");
|
||||||
@@ -61,6 +65,16 @@ export default function Notifications() {
|
|||||||
queryFn: apiClient.getNotificationServices,
|
queryFn: apiClient.getNotificationServices,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: syncOperations,
|
||||||
|
isLoading: syncOperationsLoading,
|
||||||
|
error: syncOperationsError,
|
||||||
|
refetch: refetchSyncOperations,
|
||||||
|
} = useQuery<SyncOperationsResponse>({
|
||||||
|
queryKey: ["syncOperations"],
|
||||||
|
queryFn: () => apiClient.getSyncOperations(10, 0), // Get latest 10 operations
|
||||||
|
});
|
||||||
|
|
||||||
const testMutation = useMutation({
|
const testMutation = useMutation({
|
||||||
mutationFn: apiClient.testNotification,
|
mutationFn: apiClient.testNotification,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -80,15 +94,15 @@ export default function Notifications() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (settingsLoading || servicesLoading) {
|
if (settingsLoading || servicesLoading || syncOperationsLoading) {
|
||||||
return <NotificationsSkeleton />;
|
return <NotificationsSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settingsError || servicesError) {
|
if (settingsError || servicesError || syncOperationsError) {
|
||||||
return (
|
return (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertTitle>Failed to load notifications</AlertTitle>
|
<AlertTitle>Failed to load system data</AlertTitle>
|
||||||
<AlertDescription className="space-y-3">
|
<AlertDescription className="space-y-3">
|
||||||
<p>
|
<p>
|
||||||
Unable to connect to the Leggen API. Please check your configuration
|
Unable to connect to the Leggen API. Please check your configuration
|
||||||
@@ -98,6 +112,7 @@ export default function Notifications() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
refetchSettings();
|
refetchSettings();
|
||||||
refetchServices();
|
refetchServices();
|
||||||
|
refetchSyncOperations();
|
||||||
}}
|
}}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -131,6 +146,100 @@ export default function Notifications() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Sync Operations Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center space-x-2">
|
||||||
|
<Activity className="h-5 w-5 text-primary" />
|
||||||
|
<span>Sync Operations</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Recent synchronization activities</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!syncOperations || syncOperations.operations.length === 0 ? (
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<Activity className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
|
No sync operations yet
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Sync operations will appear here once you start syncing your accounts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{syncOperations.operations.slice(0, 5).map((operation) => {
|
||||||
|
const startedAt = new Date(operation.started_at);
|
||||||
|
const isRunning = !operation.completed_at;
|
||||||
|
const duration = operation.duration_seconds
|
||||||
|
? `${Math.round(operation.duration_seconds)}s`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={operation.id}
|
||||||
|
className="flex items-center justify-between p-4 border rounded-lg hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className={`p-2 rounded-full ${
|
||||||
|
isRunning
|
||||||
|
? 'bg-blue-100 text-blue-600'
|
||||||
|
: operation.success
|
||||||
|
? 'bg-green-100 text-green-600'
|
||||||
|
: 'bg-red-100 text-red-600'
|
||||||
|
}`}>
|
||||||
|
{isRunning ? (
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
|
) : operation.success ? (
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<h4 className="text-sm font-medium text-foreground">
|
||||||
|
{isRunning ? 'Sync Running' : operation.success ? 'Sync Completed' : 'Sync Failed'}
|
||||||
|
</h4>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{operation.trigger_type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4 mt-1 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center space-x-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
<span>{startedAt.toLocaleDateString()} {startedAt.toLocaleTimeString()}</span>
|
||||||
|
</span>
|
||||||
|
{duration && (
|
||||||
|
<span>Duration: {duration}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<User className="h-3 w-3" />
|
||||||
|
<span>{operation.accounts_processed} accounts</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 mt-1">
|
||||||
|
<TrendingUp className="h-3 w-3" />
|
||||||
|
<span>{operation.transactions_added} new transactions</span>
|
||||||
|
</div>
|
||||||
|
{operation.errors.length > 0 && (
|
||||||
|
<div className="flex items-center space-x-2 mt-1 text-red-600">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
<span>{operation.errors.length} errors</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Test Notification Section */}
|
{/* Test Notification Section */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ export function PWAInstallPrompt({ onInstall }: PWAPromptProps) {
|
|||||||
try {
|
try {
|
||||||
await deferredPrompt.prompt();
|
await deferredPrompt.prompt();
|
||||||
const { outcome } = await deferredPrompt.userChoice;
|
const { outcome } = await deferredPrompt.userChoice;
|
||||||
|
|
||||||
if (outcome === "accepted") {
|
if (outcome === "accepted") {
|
||||||
onInstall?.();
|
onInstall?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
setDeferredPrompt(null);
|
setDeferredPrompt(null);
|
||||||
setShowPrompt(false);
|
setShowPrompt(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -153,4 +153,4 @@ export function PWAUpdatePrompt({ updateAvailable, onUpdate }: PWAUpdatePromptPr
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
import { Link, useLocation } from "@tanstack/react-router";
|
|
||||||
import {
|
|
||||||
List,
|
|
||||||
BarChart3,
|
|
||||||
Bell,
|
|
||||||
TrendingUp,
|
|
||||||
X,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
Settings,
|
|
||||||
Building2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Logo } from "./ui/logo";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { apiClient } from "../lib/api";
|
|
||||||
import { formatCurrency } from "../lib/utils";
|
|
||||||
import { cn } from "../lib/utils";
|
|
||||||
import { useState } from "react";
|
|
||||||
import type { Account } from "../types/api";
|
|
||||||
|
|
||||||
|
|
||||||
const navigation = [
|
|
||||||
{ name: "Overview", icon: List, to: "/" },
|
|
||||||
{ name: "Analytics", icon: BarChart3, to: "/analytics" },
|
|
||||||
{ name: "Notifications", icon: Bell, to: "/notifications" },
|
|
||||||
{ name: "Settings", icon: Settings, to: "/settings" },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface SidebarProps {
|
|
||||||
sidebarOpen: boolean;
|
|
||||||
setSidebarOpen: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
|
|
||||||
const location = useLocation();
|
|
||||||
const [accountsExpanded, setAccountsExpanded] = useState(false);
|
|
||||||
|
|
||||||
const { data: accounts } = useQuery<Account[]>({
|
|
||||||
queryKey: ["accounts"],
|
|
||||||
queryFn: apiClient.getAccounts,
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalBalance =
|
|
||||||
accounts?.reduce((sum, account) => {
|
|
||||||
const primaryBalance = account.balances?.[0]?.amount || 0;
|
|
||||||
return sum + primaryBalance;
|
|
||||||
}, 0) || 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"fixed inset-y-0 left-0 z-50 w-64 bg-card shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0",
|
|
||||||
sidebarOpen ? "translate-x-0" : "-translate-x-full",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between h-16 px-6 border-b border-border">
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
onClick={() => setSidebarOpen(false)}
|
|
||||||
className="flex items-center space-x-2 hover:opacity-80 transition-opacity"
|
|
||||||
>
|
|
||||||
<Logo size={32} />
|
|
||||||
<h1 className="text-xl font-bold text-card-foreground">Leggen</h1>
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
onClick={() => setSidebarOpen(false)}
|
|
||||||
className="lg:hidden p-1 rounded-md text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<X className="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="px-6 py-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
{navigation.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.to}
|
|
||||||
to={item.to}
|
|
||||||
onClick={() => setSidebarOpen(false)}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center w-full px-3 py-2 text-sm font-medium rounded-md transition-colors",
|
|
||||||
location.pathname === item.to
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "text-card-foreground hover:text-card-foreground hover:bg-accent",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<item.icon className="mr-3 h-5 w-5" />
|
|
||||||
{item.name}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Collapsible Account Summary in Sidebar */}
|
|
||||||
<div className="px-6 pt-4 pb-6 border-t border-border mt-auto">
|
|
||||||
<div className="bg-muted rounded-lg">
|
|
||||||
{/* Collapsible Header */}
|
|
||||||
<button
|
|
||||||
onClick={() => setAccountsExpanded(!accountsExpanded)}
|
|
||||||
className="w-full p-4 flex items-center justify-between hover:bg-muted/80 transition-colors rounded-lg"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">
|
|
||||||
Total Balance
|
|
||||||
</span>
|
|
||||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
|
||||||
</div>
|
|
||||||
{accountsExpanded ? (
|
|
||||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="px-4 pb-2">
|
|
||||||
<p className="text-2xl font-bold text-foreground">
|
|
||||||
{formatCurrency(totalBalance)}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{accounts?.length || 0} accounts
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expanded Account Details */}
|
|
||||||
{accountsExpanded && accounts && accounts.length > 0 && (
|
|
||||||
<div className="border-t border-border/50 max-h-64 overflow-y-auto">
|
|
||||||
{accounts.map((account) => {
|
|
||||||
const primaryBalance = account.balances?.[0]?.amount || 0;
|
|
||||||
const currency = account.balances?.[0]?.currency || account.currency || "EUR";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={account.id}
|
|
||||||
className="p-3 border-b border-border/30 last:border-b-0 hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-start space-x-2">
|
|
||||||
<div className="flex-shrink-0 p-1 bg-background rounded">
|
|
||||||
<Building2 className="h-3 w-3 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 min-w-0 flex-1">
|
|
||||||
<p className="text-sm font-medium text-foreground truncate">
|
|
||||||
{account.display_name || account.name || "Unnamed Account"}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-semibold text-foreground">
|
|
||||||
{formatCurrency(primaryBalance, currency)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useLocation } from "@tanstack/react-router";
|
import { useLocation } from "@tanstack/react-router";
|
||||||
import { Menu, Activity, Wifi, WifiOff } from "lucide-react";
|
import { Activity, Wifi, WifiOff } from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { apiClient } from "../lib/api";
|
import { apiClient } from "../lib/api";
|
||||||
import { ThemeToggle } from "./ui/theme-toggle";
|
import { ThemeToggle } from "./ui/theme-toggle";
|
||||||
|
import { Separator } from "./ui/separator";
|
||||||
|
import { SidebarTrigger } from "./ui/sidebar";
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: "Overview", to: "/" },
|
{ name: "Overview", to: "/" },
|
||||||
@@ -12,11 +14,7 @@ const navigation = [
|
|||||||
{ name: "Settings", to: "/settings" },
|
{ name: "Settings", to: "/settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface HeaderProps {
|
export function SiteHeader() {
|
||||||
setSidebarOpen: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Header({ setSidebarOpen }: HeaderProps) {
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const currentPage =
|
const currentPage =
|
||||||
navigation.find((item) => item.to === location.pathname)?.name ||
|
navigation.find((item) => item.to === location.pathname)?.name ||
|
||||||
@@ -33,20 +31,18 @@ export default function Header({ setSidebarOpen }: HeaderProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="lg:static sticky top-0 z-50 bg-card shadow-sm border-b border-border pt-safe-top">
|
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear pt-safe-top">
|
||||||
<div className="flex items-center justify-between h-16 px-6">
|
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
||||||
<div className="flex items-center">
|
<SidebarTrigger className="-ml-1" />
|
||||||
<button
|
<Separator
|
||||||
onClick={() => setSidebarOpen(true)}
|
orientation="vertical"
|
||||||
className="lg:hidden p-1 rounded-md text-muted-foreground hover:text-foreground"
|
className="mx-2 data-[orientation=vertical]:h-4"
|
||||||
>
|
/>
|
||||||
<Menu className="h-6 w-6" />
|
<h1 className="text-lg font-semibold text-card-foreground">
|
||||||
</button>
|
{currentPage}
|
||||||
<h2 className="text-lg font-semibold text-card-foreground lg:ml-0 ml-4">
|
</h1>
|
||||||
{currentPage}
|
|
||||||
</h2>
|
<div className="ml-auto flex items-center space-x-3">
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
{healthLoading ? (
|
{healthLoading ? (
|
||||||
<>
|
<>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as React from "react";
|
import * as React from "react"
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
@@ -31,27 +31,27 @@ const buttonVariants = cva(
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
VariantProps<typeof buttonVariants> {
|
VariantProps<typeof buttonVariants> {
|
||||||
asChild?: boolean;
|
asChild?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
const Comp = asChild ? Slot : "button";
|
const Comp = asChild ? Slot : "button"
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
Button.displayName = "Button";
|
Button.displayName = "Button"
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
export { Button, buttonVariants }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
({ className, type, ...props }, ref) => {
|
({ className, type, ...props }, ref) => {
|
||||||
@@ -9,14 +9,14 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
|||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
Input.displayName = "Input";
|
Input.displayName = "Input"
|
||||||
|
|
||||||
export { Input };
|
export { Input }
|
||||||
|
|||||||
29
frontend/src/components/ui/separator.tsx
Normal file
29
frontend/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border",
|
||||||
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
import * as React from "react";
|
"use client"
|
||||||
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import * as React from "react"
|
||||||
|
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
const Sheet = SheetPrimitive.Root;
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const SheetTrigger = SheetPrimitive.Trigger;
|
const Sheet = SheetPrimitive.Root
|
||||||
|
|
||||||
const SheetClose = SheetPrimitive.Close;
|
const SheetTrigger = SheetPrimitive.Trigger
|
||||||
|
|
||||||
const SheetPortal = SheetPrimitive.Portal;
|
const SheetClose = SheetPrimitive.Close
|
||||||
|
|
||||||
|
const SheetPortal = SheetPrimitive.Portal
|
||||||
|
|
||||||
const SheetOverlay = React.forwardRef<
|
const SheetOverlay = React.forwardRef<
|
||||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||||
@@ -20,16 +22,16 @@ const SheetOverlay = React.forwardRef<
|
|||||||
<SheetPrimitive.Overlay
|
<SheetPrimitive.Overlay
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
));
|
))
|
||||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||||
|
|
||||||
const sheetVariants = cva(
|
const sheetVariants = cva(
|
||||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
side: {
|
side: {
|
||||||
@@ -38,14 +40,14 @@ const sheetVariants = cva(
|
|||||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||||
right:
|
right:
|
||||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
side: "right",
|
side: "right",
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
)
|
||||||
|
|
||||||
interface SheetContentProps
|
interface SheetContentProps
|
||||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||||
@@ -62,15 +64,15 @@ const SheetContent = React.forwardRef<
|
|||||||
className={cn(sheetVariants({ side }), className)}
|
className={cn(sheetVariants({ side }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
|
||||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</SheetPrimitive.Close>
|
</SheetPrimitive.Close>
|
||||||
|
{children}
|
||||||
</SheetPrimitive.Content>
|
</SheetPrimitive.Content>
|
||||||
</SheetPortal>
|
</SheetPortal>
|
||||||
));
|
))
|
||||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||||
|
|
||||||
const SheetHeader = ({
|
const SheetHeader = ({
|
||||||
className,
|
className,
|
||||||
@@ -79,12 +81,12 @@ const SheetHeader = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col space-y-2 text-center sm:text-left",
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
SheetHeader.displayName = "SheetHeader";
|
SheetHeader.displayName = "SheetHeader"
|
||||||
|
|
||||||
const SheetFooter = ({
|
const SheetFooter = ({
|
||||||
className,
|
className,
|
||||||
@@ -93,12 +95,12 @@ const SheetFooter = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
SheetFooter.displayName = "SheetFooter";
|
SheetFooter.displayName = "SheetFooter"
|
||||||
|
|
||||||
const SheetTitle = React.forwardRef<
|
const SheetTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||||
@@ -109,8 +111,8 @@ const SheetTitle = React.forwardRef<
|
|||||||
className={cn("text-lg font-semibold text-foreground", className)}
|
className={cn("text-lg font-semibold text-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
))
|
||||||
SheetTitle.displayName = SheetPrimitive.Title.displayName;
|
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||||
|
|
||||||
const SheetDescription = React.forwardRef<
|
const SheetDescription = React.forwardRef<
|
||||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||||
@@ -121,8 +123,8 @@ const SheetDescription = React.forwardRef<
|
|||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
))
|
||||||
SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Sheet,
|
Sheet,
|
||||||
@@ -135,4 +137,4 @@ export {
|
|||||||
SheetFooter,
|
SheetFooter,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetDescription,
|
SheetDescription,
|
||||||
};
|
}
|
||||||
|
|||||||
771
frontend/src/components/ui/sidebar.tsx
Normal file
771
frontend/src/components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,771 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { PanelLeft } from "lucide-react"
|
||||||
|
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
|
||||||
|
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||||
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||||
|
const SIDEBAR_WIDTH = "16rem"
|
||||||
|
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||||
|
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||||
|
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||||
|
|
||||||
|
type SidebarContextProps = {
|
||||||
|
state: "expanded" | "collapsed"
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
openMobile: boolean
|
||||||
|
setOpenMobile: (open: boolean) => void
|
||||||
|
isMobile: boolean
|
||||||
|
toggleSidebar: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||||
|
|
||||||
|
function useSidebar() {
|
||||||
|
const context = React.useContext(SidebarContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarProvider = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
defaultOpen?: boolean
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
defaultOpen = true,
|
||||||
|
open: openProp,
|
||||||
|
onOpenChange: setOpenProp,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const [openMobile, setOpenMobile] = React.useState(false)
|
||||||
|
|
||||||
|
// This is the internal state of the sidebar.
|
||||||
|
// We use openProp and setOpenProp for control from outside the component.
|
||||||
|
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||||
|
const open = openProp ?? _open
|
||||||
|
const setOpen = React.useCallback(
|
||||||
|
(value: boolean | ((value: boolean) => boolean)) => {
|
||||||
|
const openState = typeof value === "function" ? value(open) : value
|
||||||
|
if (setOpenProp) {
|
||||||
|
setOpenProp(openState)
|
||||||
|
} else {
|
||||||
|
_setOpen(openState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This sets the cookie to keep the sidebar state.
|
||||||
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||||
|
},
|
||||||
|
[setOpenProp, open]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper to toggle the sidebar.
|
||||||
|
const toggleSidebar = React.useCallback(() => {
|
||||||
|
return isMobile
|
||||||
|
? setOpenMobile((open) => !open)
|
||||||
|
: setOpen((open) => !open)
|
||||||
|
}, [isMobile, setOpen, setOpenMobile])
|
||||||
|
|
||||||
|
// Adds a keyboard shortcut to toggle the sidebar.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||||
|
(event.metaKey || event.ctrlKey)
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
toggleSidebar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}, [toggleSidebar])
|
||||||
|
|
||||||
|
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||||
|
// This makes it easier to style the sidebar with Tailwind classes.
|
||||||
|
const state = open ? "expanded" : "collapsed"
|
||||||
|
|
||||||
|
const contextValue = React.useMemo<SidebarContextProps>(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
isMobile,
|
||||||
|
openMobile,
|
||||||
|
setOpenMobile,
|
||||||
|
toggleSidebar,
|
||||||
|
}),
|
||||||
|
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarContext.Provider value={contextValue}>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<div
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": SIDEBAR_WIDTH,
|
||||||
|
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||||
|
...style,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
SidebarProvider.displayName = "SidebarProvider"
|
||||||
|
|
||||||
|
const Sidebar = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
side?: "left" | "right"
|
||||||
|
variant?: "sidebar" | "floating" | "inset"
|
||||||
|
collapsible?: "offcanvas" | "icon" | "none"
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
side = "left",
|
||||||
|
variant = "sidebar",
|
||||||
|
collapsible = "offcanvas",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||||
|
|
||||||
|
if (collapsible === "none") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||||
|
<SheetContent
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-mobile="true"
|
||||||
|
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
side={side}
|
||||||
|
>
|
||||||
|
<SheetHeader className="sr-only">
|
||||||
|
<SheetTitle>Sidebar</SheetTitle>
|
||||||
|
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex h-full w-full flex-col">{children}</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="group peer hidden text-sidebar-foreground md:block"
|
||||||
|
data-state={state}
|
||||||
|
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||||
|
data-variant={variant}
|
||||||
|
data-side={side}
|
||||||
|
>
|
||||||
|
{/* This is what handles the sidebar gap on desktop */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
|
||||||
|
"group-data-[collapsible=offcanvas]:w-0",
|
||||||
|
"group-data-[side=right]:rotate-180",
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
|
||||||
|
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||||
|
side === "left"
|
||||||
|
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||||
|
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||||
|
// Adjust the padding for floating and inset variants.
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
|
||||||
|
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Sidebar.displayName = "Sidebar"
|
||||||
|
|
||||||
|
const SidebarTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Button>,
|
||||||
|
React.ComponentProps<typeof Button>
|
||||||
|
>(({ className, onClick, ...props }, ref) => {
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="trigger"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn("h-7 w-7", className)}
|
||||||
|
onClick={(event) => {
|
||||||
|
onClick?.(event)
|
||||||
|
toggleSidebar()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<PanelLeft />
|
||||||
|
<span className="sr-only">Toggle Sidebar</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarTrigger.displayName = "SidebarTrigger"
|
||||||
|
|
||||||
|
const SidebarRail = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<"button">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="rail"
|
||||||
|
aria-label="Toggle Sidebar"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
title="Toggle Sidebar"
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
||||||
|
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
||||||
|
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||||
|
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
||||||
|
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||||
|
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarRail.displayName = "SidebarRail"
|
||||||
|
|
||||||
|
const SidebarInset = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"main">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full flex-1 flex-col bg-background",
|
||||||
|
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarInset.displayName = "SidebarInset"
|
||||||
|
|
||||||
|
const SidebarInput = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Input>,
|
||||||
|
React.ComponentProps<typeof Input>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarInput.displayName = "SidebarInput"
|
||||||
|
|
||||||
|
const SidebarHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="header"
|
||||||
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarHeader.displayName = "SidebarHeader"
|
||||||
|
|
||||||
|
const SidebarFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="footer"
|
||||||
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarFooter.displayName = "SidebarFooter"
|
||||||
|
|
||||||
|
const SidebarSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Separator>,
|
||||||
|
React.ComponentProps<typeof Separator>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="separator"
|
||||||
|
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarSeparator.displayName = "SidebarSeparator"
|
||||||
|
|
||||||
|
const SidebarContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="content"
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarContent.displayName = "SidebarContent"
|
||||||
|
|
||||||
|
const SidebarGroup = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="group"
|
||||||
|
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarGroup.displayName = "SidebarGroup"
|
||||||
|
|
||||||
|
const SidebarGroupLabel = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> & { asChild?: boolean }
|
||||||
|
>(({ className, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "div"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="group-label"
|
||||||
|
className={cn(
|
||||||
|
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarGroupLabel.displayName = "SidebarGroupLabel"
|
||||||
|
|
||||||
|
const SidebarGroupAction = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<"button"> & { asChild?: boolean }
|
||||||
|
>(({ className, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="group-action"
|
||||||
|
className={cn(
|
||||||
|
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
|
"after:absolute after:-inset-2 after:md:hidden",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarGroupAction.displayName = "SidebarGroupAction"
|
||||||
|
|
||||||
|
const SidebarGroupContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="group-content"
|
||||||
|
className={cn("w-full text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarGroupContent.displayName = "SidebarGroupContent"
|
||||||
|
|
||||||
|
const SidebarMenu = React.forwardRef<
|
||||||
|
HTMLUListElement,
|
||||||
|
React.ComponentProps<"ul">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ul
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu"
|
||||||
|
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarMenu.displayName = "SidebarMenu"
|
||||||
|
|
||||||
|
const SidebarMenuItem = React.forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
React.ComponentProps<"li">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<li
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-item"
|
||||||
|
className={cn("group/menu-item relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarMenuItem.displayName = "SidebarMenuItem"
|
||||||
|
|
||||||
|
const sidebarMenuButtonVariants = cva(
|
||||||
|
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||||
|
outline:
|
||||||
|
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-8 text-sm",
|
||||||
|
sm: "h-7 text-xs",
|
||||||
|
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const SidebarMenuButton = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<"button"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
isActive?: boolean
|
||||||
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||||
|
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
asChild = false,
|
||||||
|
isActive = false,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
tooltip,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
const { isMobile, state } = useSidebar()
|
||||||
|
|
||||||
|
const button = (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!tooltip) {
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof tooltip === "string") {
|
||||||
|
tooltip = {
|
||||||
|
children: tooltip,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
hidden={state !== "collapsed" || isMobile}
|
||||||
|
{...tooltip}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
SidebarMenuButton.displayName = "SidebarMenuButton"
|
||||||
|
|
||||||
|
const SidebarMenuAction = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<"button"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
showOnHover?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-action"
|
||||||
|
className={cn(
|
||||||
|
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
|
"after:absolute after:-inset-2 after:md:hidden",
|
||||||
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
showOnHover &&
|
||||||
|
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarMenuAction.displayName = "SidebarMenuAction"
|
||||||
|
|
||||||
|
const SidebarMenuBadge = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-badge"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
|
||||||
|
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||||
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarMenuBadge.displayName = "SidebarMenuBadge"
|
||||||
|
|
||||||
|
const SidebarMenuSkeleton = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
showIcon?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, showIcon = false, ...props }, ref) => {
|
||||||
|
// Random width between 50 to 90%.
|
||||||
|
const width = React.useMemo(() => {
|
||||||
|
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-skeleton"
|
||||||
|
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{showIcon && (
|
||||||
|
<Skeleton
|
||||||
|
className="size-4 rounded-md"
|
||||||
|
data-sidebar="menu-skeleton-icon"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Skeleton
|
||||||
|
className="h-4 max-w-[--skeleton-width] flex-1"
|
||||||
|
data-sidebar="menu-skeleton-text"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--skeleton-width": width,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
|
||||||
|
|
||||||
|
const SidebarMenuSub = React.forwardRef<
|
||||||
|
HTMLUListElement,
|
||||||
|
React.ComponentProps<"ul">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ul
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-sub"
|
||||||
|
className={cn(
|
||||||
|
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SidebarMenuSub.displayName = "SidebarMenuSub"
|
||||||
|
|
||||||
|
const SidebarMenuSubItem = React.forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
React.ComponentProps<"li">
|
||||||
|
>(({ ...props }, ref) => <li ref={ref} {...props} />)
|
||||||
|
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
|
||||||
|
|
||||||
|
const SidebarMenuSubButton = React.forwardRef<
|
||||||
|
HTMLAnchorElement,
|
||||||
|
React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
size?: "sm" | "md"
|
||||||
|
isActive?: boolean
|
||||||
|
}
|
||||||
|
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
ref={ref}
|
||||||
|
data-sidebar="menu-sub-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||||
|
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||||
|
size === "sm" && "text-xs",
|
||||||
|
size === "md" && "text-sm",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupAction,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInput,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
|
SidebarMenuBadge,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSkeleton,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarRail,
|
||||||
|
SidebarSeparator,
|
||||||
|
SidebarTrigger,
|
||||||
|
useSidebar,
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Skeleton({
|
function Skeleton({
|
||||||
className,
|
className,
|
||||||
@@ -6,10 +6,10 @@ function Skeleton({
|
|||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Skeleton };
|
export { Skeleton }
|
||||||
|
|||||||
30
frontend/src/components/ui/tooltip.tsx
Normal file
30
frontend/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
))
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
19
frontend/src/hooks/use-mobile.tsx
Normal file
19
frontend/src/hooks/use-mobile.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||||
|
const onChange = () => {
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
}
|
||||||
|
mql.addEventListener("change", onChange)
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
return () => mql.removeEventListener("change", onChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return !!isMobile
|
||||||
|
}
|
||||||
@@ -34,4 +34,4 @@ export function usePWA(): PWAUpdate {
|
|||||||
updateAvailable,
|
updateAvailable,
|
||||||
updateSW,
|
updateSW,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,12 +29,20 @@
|
|||||||
--chart-4: 43 74% 66%;
|
--chart-4: 43 74% 66%;
|
||||||
--chart-5: 27 87% 67%;
|
--chart-5: 27 87% 67%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
|
||||||
/* iOS Safe Area Support for PWA */
|
/* iOS Safe Area Support for PWA */
|
||||||
--safe-area-inset-top: env(safe-area-inset-top, 0px);
|
--safe-area-inset-top: env(safe-area-inset-top, 0px);
|
||||||
--safe-area-inset-right: env(safe-area-inset-right, 0px);
|
--safe-area-inset-right: env(safe-area-inset-right, 0px);
|
||||||
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
|
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
--safe-area-inset-left: env(safe-area-inset-left, 0px);
|
--safe-area-inset-left: env(safe-area-inset-left, 0px);
|
||||||
|
--sidebar-background: 0 0% 98%;
|
||||||
|
--sidebar-foreground: 240 5.3% 26.1%;
|
||||||
|
--sidebar-primary: 240 5.9% 10%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 98%;
|
||||||
|
--sidebar-accent: 240 4.8% 95.9%;
|
||||||
|
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||||
|
--sidebar-border: 220 13% 91%;
|
||||||
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
}
|
}
|
||||||
.dark {
|
.dark {
|
||||||
--background: 222.2 84% 4.9%;
|
--background: 222.2 84% 4.9%;
|
||||||
@@ -61,6 +69,14 @@
|
|||||||
--chart-3: 30 80% 55%;
|
--chart-3: 30 80% 55%;
|
||||||
--chart-4: 280 65% 60%;
|
--chart-4: 280 65% 60%;
|
||||||
--chart-5: 340 75% 55%;
|
--chart-5: 340 75% 55%;
|
||||||
|
--sidebar-background: 240 5.9% 10%;
|
||||||
|
--sidebar-foreground: 240 4.8% 95.9%;
|
||||||
|
--sidebar-primary: 224.3 76.3% 48%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 240 3.7% 15.9%;
|
||||||
|
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||||
|
--sidebar-border: 240 3.7% 15.9%;
|
||||||
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
HealthData,
|
HealthData,
|
||||||
AccountUpdate,
|
AccountUpdate,
|
||||||
TransactionStats,
|
TransactionStats,
|
||||||
|
SyncOperationsResponse,
|
||||||
} from "../types/api";
|
} from "../types/api";
|
||||||
|
|
||||||
// Use VITE_API_URL for development, relative URLs for production
|
// Use VITE_API_URL for development, relative URLs for production
|
||||||
@@ -217,6 +218,17 @@ export const apiClient = {
|
|||||||
>(`/transactions/monthly-stats?${queryParams.toString()}`);
|
>(`/transactions/monthly-stats?${queryParams.toString()}`);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Get sync operations history
|
||||||
|
getSyncOperations: async (
|
||||||
|
limit: number = 50,
|
||||||
|
offset: number = 0,
|
||||||
|
): Promise<SyncOperationsResponse> => {
|
||||||
|
const response = await api.get<ApiResponse<SyncOperationsResponse>>(
|
||||||
|
`/sync/operations?limit=${limit}&offset=${offset}`,
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default apiClient;
|
export default apiClient;
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { AppSidebar } from "../components/AppSidebar";
|
||||||
import Sidebar from "../components/Sidebar";
|
import { SiteHeader } from "../components/SiteHeader";
|
||||||
import Header from "../components/Header";
|
|
||||||
import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
|
import { PWAInstallPrompt, PWAUpdatePrompt } from "../components/PWAPrompts";
|
||||||
import { usePWA } from "../hooks/usePWA";
|
import { usePWA } from "../hooks/usePWA";
|
||||||
|
import {
|
||||||
|
SidebarInset,
|
||||||
|
SidebarProvider,
|
||||||
|
} from "../components/ui/sidebar";
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
||||||
const { updateAvailable, updateSW } = usePWA();
|
const { updateAvailable, updateSW } = usePWA();
|
||||||
|
|
||||||
const handlePWAInstall = () => {
|
const handlePWAInstall = () => {
|
||||||
@@ -23,23 +25,21 @@ function RootLayout() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-background">
|
<SidebarProvider
|
||||||
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
|
style={
|
||||||
|
{
|
||||||
{/* Mobile overlay */}
|
"--sidebar-width": "16rem",
|
||||||
{sidebarOpen && (
|
"--header-height": "4rem",
|
||||||
<div
|
} as React.CSSProperties
|
||||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
}
|
||||||
onClick={() => setSidebarOpen(false)}
|
>
|
||||||
/>
|
<AppSidebar />
|
||||||
)}
|
<SidebarInset>
|
||||||
|
<SiteHeader />
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
|
||||||
<Header setSidebarOpen={setSidebarOpen} />
|
|
||||||
<main className="flex-1 p-6 min-w-0">
|
<main className="flex-1 p-6 min-w-0">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</SidebarInset>
|
||||||
|
|
||||||
{/* PWA Prompts */}
|
{/* PWA Prompts */}
|
||||||
<PWAInstallPrompt onInstall={handlePWAInstall} />
|
<PWAInstallPrompt onInstall={handlePWAInstall} />
|
||||||
@@ -47,7 +47,7 @@ function RootLayout() {
|
|||||||
updateAvailable={updateAvailable}
|
updateAvailable={updateAvailable}
|
||||||
onUpdate={handlePWAUpdate}
|
onUpdate={handlePWAUpdate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -213,3 +213,24 @@ export interface TransactionStats {
|
|||||||
average_transaction: number;
|
average_transaction: number;
|
||||||
accounts_included: number;
|
accounts_included: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync operations types
|
||||||
|
export interface SyncOperation {
|
||||||
|
id: number;
|
||||||
|
started_at: string;
|
||||||
|
completed_at?: string;
|
||||||
|
success?: boolean;
|
||||||
|
accounts_processed: number;
|
||||||
|
transactions_added: number;
|
||||||
|
transactions_updated: number;
|
||||||
|
balances_updated: number;
|
||||||
|
duration_seconds?: number;
|
||||||
|
errors: string[];
|
||||||
|
logs: string[];
|
||||||
|
trigger_type: 'manual' | 'scheduled' | 'api';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncOperationsResponse {
|
||||||
|
operations: SyncOperation[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,61 +3,71 @@ export default {
|
|||||||
darkMode: ["class"],
|
darkMode: ["class"],
|
||||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
lg: 'var(--radius)',
|
||||||
md: "calc(var(--radius) - 2px)",
|
md: 'calc(var(--radius) - 2px)',
|
||||||
sm: "calc(var(--radius) - 4px)",
|
sm: 'calc(var(--radius) - 4px)'
|
||||||
},
|
},
|
||||||
spacing: {
|
spacing: {
|
||||||
'safe-top': 'var(--safe-area-inset-top)',
|
'safe-top': 'var(--safe-area-inset-top)',
|
||||||
'safe-bottom': 'var(--safe-area-inset-bottom)',
|
'safe-bottom': 'var(--safe-area-inset-bottom)',
|
||||||
'safe-left': 'var(--safe-area-inset-left)',
|
'safe-left': 'var(--safe-area-inset-left)',
|
||||||
'safe-right': 'var(--safe-area-inset-right)',
|
'safe-right': 'var(--safe-area-inset-right)'
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
background: "hsl(var(--background))",
|
background: 'hsl(var(--background))',
|
||||||
foreground: "hsl(var(--foreground))",
|
foreground: 'hsl(var(--foreground))',
|
||||||
card: {
|
card: {
|
||||||
DEFAULT: "hsl(var(--card))",
|
DEFAULT: 'hsl(var(--card))',
|
||||||
foreground: "hsl(var(--card-foreground))",
|
foreground: 'hsl(var(--card-foreground))'
|
||||||
},
|
},
|
||||||
popover: {
|
popover: {
|
||||||
DEFAULT: "hsl(var(--popover))",
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
foreground: "hsl(var(--popover-foreground))",
|
foreground: 'hsl(var(--popover-foreground))'
|
||||||
},
|
},
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: "hsl(var(--primary))",
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
foreground: "hsl(var(--primary-foreground))",
|
foreground: 'hsl(var(--primary-foreground))'
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
DEFAULT: "hsl(var(--secondary))",
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
foreground: "hsl(var(--secondary-foreground))",
|
foreground: 'hsl(var(--secondary-foreground))'
|
||||||
},
|
},
|
||||||
muted: {
|
muted: {
|
||||||
DEFAULT: "hsl(var(--muted))",
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
foreground: "hsl(var(--muted-foreground))",
|
foreground: 'hsl(var(--muted-foreground))'
|
||||||
},
|
},
|
||||||
accent: {
|
accent: {
|
||||||
DEFAULT: "hsl(var(--accent))",
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
foreground: "hsl(var(--accent-foreground))",
|
foreground: 'hsl(var(--accent-foreground))'
|
||||||
},
|
},
|
||||||
destructive: {
|
destructive: {
|
||||||
DEFAULT: "hsl(var(--destructive))",
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
foreground: "hsl(var(--destructive-foreground))",
|
foreground: 'hsl(var(--destructive-foreground))'
|
||||||
},
|
},
|
||||||
border: "hsl(var(--border))",
|
border: 'hsl(var(--border))',
|
||||||
input: "hsl(var(--input))",
|
input: 'hsl(var(--input))',
|
||||||
ring: "hsl(var(--ring))",
|
ring: 'hsl(var(--ring))',
|
||||||
chart: {
|
chart: {
|
||||||
1: "hsl(var(--chart-1))",
|
'1': 'hsl(var(--chart-1))',
|
||||||
2: "hsl(var(--chart-2))",
|
'2': 'hsl(var(--chart-2))',
|
||||||
3: "hsl(var(--chart-3))",
|
'3': 'hsl(var(--chart-3))',
|
||||||
4: "hsl(var(--chart-4))",
|
'4': 'hsl(var(--chart-4))',
|
||||||
5: "hsl(var(--chart-5))",
|
'5': 'hsl(var(--chart-5))'
|
||||||
},
|
},
|
||||||
},
|
sidebar: {
|
||||||
},
|
DEFAULT: 'hsl(var(--sidebar-background))',
|
||||||
|
foreground: 'hsl(var(--sidebar-foreground))',
|
||||||
|
primary: 'hsl(var(--sidebar-primary))',
|
||||||
|
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
|
||||||
|
accent: 'hsl(var(--sidebar-accent))',
|
||||||
|
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
|
||||||
|
border: 'hsl(var(--sidebar-border))',
|
||||||
|
ring: 'hsl(var(--sidebar-ring))'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
plugins: [require("@tailwindcss/forms"), require("tailwindcss-animate")],
|
plugins: [require("@tailwindcss/forms"), require("tailwindcss-animate")],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,26 @@ from typing import Optional
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class SyncOperation(BaseModel):
|
||||||
|
"""Sync operation record for tracking sync history"""
|
||||||
|
|
||||||
|
id: Optional[int] = None
|
||||||
|
started_at: datetime
|
||||||
|
completed_at: Optional[datetime] = None
|
||||||
|
success: Optional[bool] = None
|
||||||
|
accounts_processed: int = 0
|
||||||
|
transactions_added: int = 0
|
||||||
|
transactions_updated: int = 0
|
||||||
|
balances_updated: int = 0
|
||||||
|
duration_seconds: Optional[float] = None
|
||||||
|
errors: list[str] = []
|
||||||
|
logs: list[str] = []
|
||||||
|
trigger_type: str = "manual" # manual, scheduled, api
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_encoders = {datetime: lambda v: v.isoformat() if v else None}
|
||||||
|
|
||||||
|
|
||||||
class SyncRequest(BaseModel):
|
class SyncRequest(BaseModel):
|
||||||
"""Request to trigger a sync"""
|
"""Request to trigger a sync"""
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ async def trigger_sync(
|
|||||||
sync_service.sync_specific_accounts,
|
sync_service.sync_specific_accounts,
|
||||||
sync_request.account_ids,
|
sync_request.account_ids,
|
||||||
sync_request.force if sync_request else False,
|
sync_request.force if sync_request else False,
|
||||||
|
"api", # trigger_type
|
||||||
)
|
)
|
||||||
message = (
|
message = (
|
||||||
f"Started sync for {len(sync_request.account_ids)} specific accounts"
|
f"Started sync for {len(sync_request.account_ids)} specific accounts"
|
||||||
@@ -65,6 +66,7 @@ async def trigger_sync(
|
|||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
sync_service.sync_all_accounts,
|
sync_service.sync_all_accounts,
|
||||||
sync_request.force if sync_request else False,
|
sync_request.force if sync_request else False,
|
||||||
|
"api", # trigger_type
|
||||||
)
|
)
|
||||||
message = "Started sync for all accounts"
|
message = "Started sync for all accounts"
|
||||||
|
|
||||||
@@ -90,11 +92,11 @@ async def sync_now(sync_request: Optional[SyncRequest] = None) -> APIResponse:
|
|||||||
try:
|
try:
|
||||||
if sync_request and sync_request.account_ids:
|
if sync_request and sync_request.account_ids:
|
||||||
result = await sync_service.sync_specific_accounts(
|
result = await sync_service.sync_specific_accounts(
|
||||||
sync_request.account_ids, sync_request.force
|
sync_request.account_ids, sync_request.force, "api"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
result = await sync_service.sync_all_accounts(
|
result = await sync_service.sync_all_accounts(
|
||||||
sync_request.force if sync_request else False
|
sync_request.force if sync_request else False, "api"
|
||||||
)
|
)
|
||||||
|
|
||||||
return APIResponse(
|
return APIResponse(
|
||||||
@@ -211,3 +213,24 @@ async def stop_scheduler() -> APIResponse:
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to stop scheduler: {str(e)}"
|
status_code=500, detail=f"Failed to stop scheduler: {str(e)}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sync/operations", response_model=APIResponse)
|
||||||
|
async def get_sync_operations(
|
||||||
|
limit: int = 50, offset: int = 0
|
||||||
|
) -> APIResponse:
|
||||||
|
"""Get sync operations history"""
|
||||||
|
try:
|
||||||
|
operations = await sync_service.database.get_sync_operations(limit=limit, offset=offset)
|
||||||
|
|
||||||
|
return APIResponse(
|
||||||
|
success=True,
|
||||||
|
data={"operations": operations, "count": len(operations)},
|
||||||
|
message="Sync operations retrieved successfully",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get sync operations: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to get sync operations: {str(e)}"
|
||||||
|
) from e
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ class DatabaseService:
|
|||||||
await self._migrate_null_transaction_ids_if_needed()
|
await self._migrate_null_transaction_ids_if_needed()
|
||||||
await self._migrate_to_composite_key_if_needed()
|
await self._migrate_to_composite_key_if_needed()
|
||||||
await self._migrate_add_display_name_if_needed()
|
await self._migrate_add_display_name_if_needed()
|
||||||
|
await self._migrate_add_sync_operations_if_needed()
|
||||||
|
|
||||||
async def _migrate_balance_timestamps_if_needed(self):
|
async def _migrate_balance_timestamps_if_needed(self):
|
||||||
"""Check and migrate balance timestamps if needed"""
|
"""Check and migrate balance timestamps if needed"""
|
||||||
@@ -693,7 +694,7 @@ class DatabaseService:
|
|||||||
|
|
||||||
# Add the display_name column
|
# Add the display_name column
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
ALTER TABLE accounts
|
ALTER TABLE accounts
|
||||||
ADD COLUMN display_name TEXT
|
ADD COLUMN display_name TEXT
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -857,6 +858,14 @@ class DatabaseService:
|
|||||||
|
|
||||||
for transaction in transactions:
|
for transaction in transactions:
|
||||||
try:
|
try:
|
||||||
|
# Check if transaction already exists before insertion
|
||||||
|
cursor.execute(
|
||||||
|
"""SELECT COUNT(*) FROM transactions
|
||||||
|
WHERE accountId = ? AND transactionId = ?""",
|
||||||
|
(transaction["accountId"], transaction["transactionId"]),
|
||||||
|
)
|
||||||
|
exists = cursor.fetchone()[0] > 0
|
||||||
|
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
insert_sql,
|
insert_sql,
|
||||||
(
|
(
|
||||||
@@ -873,7 +882,11 @@ class DatabaseService:
|
|||||||
json.dumps(transaction["rawTransaction"]),
|
json.dumps(transaction["rawTransaction"]),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
new_transactions.append(transaction)
|
|
||||||
|
# Only add to new_transactions if it didn't exist before
|
||||||
|
if not exists:
|
||||||
|
new_transactions.append(transaction)
|
||||||
|
|
||||||
except sqlite3.IntegrityError as e:
|
except sqlite3.IntegrityError as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Failed to insert transaction {transaction.get('transactionId')}: {e}"
|
f"Failed to insert transaction {transaction.get('transactionId')}: {e}"
|
||||||
@@ -1415,3 +1428,187 @@ class DatabaseService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
async def _check_sync_operations_migration_needed(self) -> bool:
|
||||||
|
"""Check if sync_operations table needs to be created"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
if not db_path.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if sync_operations table exists
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='sync_operations'"
|
||||||
|
)
|
||||||
|
table_exists = cursor.fetchone() is not None
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return not table_exists
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to check sync_operations migration status: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _migrate_add_sync_operations(self):
|
||||||
|
"""Add sync_operations table"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
if not db_path.exists():
|
||||||
|
logger.warning("Database file not found, skipping migration")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
logger.info("Creating sync_operations table...")
|
||||||
|
|
||||||
|
# Create the sync_operations table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE sync_operations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
started_at DATETIME NOT NULL,
|
||||||
|
completed_at DATETIME,
|
||||||
|
success BOOLEAN,
|
||||||
|
accounts_processed INTEGER DEFAULT 0,
|
||||||
|
transactions_added INTEGER DEFAULT 0,
|
||||||
|
transactions_updated INTEGER DEFAULT 0,
|
||||||
|
balances_updated INTEGER DEFAULT 0,
|
||||||
|
duration_seconds REAL,
|
||||||
|
errors TEXT,
|
||||||
|
logs TEXT,
|
||||||
|
trigger_type TEXT DEFAULT 'manual'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create indexes for better performance
|
||||||
|
cursor.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_sync_operations_started_at ON sync_operations(started_at)"
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_sync_operations_success ON sync_operations(success)"
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_sync_operations_trigger_type ON sync_operations(trigger_type)"
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logger.info("Sync operations table migration completed successfully")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Sync operations table migration failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _migrate_add_sync_operations_if_needed(self):
|
||||||
|
"""Check and add sync_operations table if needed"""
|
||||||
|
try:
|
||||||
|
if await self._check_sync_operations_migration_needed():
|
||||||
|
logger.info("Sync operations table migration needed, starting...")
|
||||||
|
await self._migrate_add_sync_operations()
|
||||||
|
logger.info("Sync operations table migration completed")
|
||||||
|
else:
|
||||||
|
logger.info("Sync operations table already exists")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Sync operations table migration failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def persist_sync_operation(self, sync_operation: Dict[str, Any]) -> int:
|
||||||
|
"""Persist sync operation to database and return the ID"""
|
||||||
|
if not self.sqlite_enabled:
|
||||||
|
logger.warning("SQLite database disabled, cannot persist sync operation")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Insert sync operation
|
||||||
|
cursor.execute(
|
||||||
|
"""INSERT INTO sync_operations (
|
||||||
|
started_at, completed_at, success, accounts_processed,
|
||||||
|
transactions_added, transactions_updated, balances_updated,
|
||||||
|
duration_seconds, errors, logs, trigger_type
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
sync_operation.get("started_at"),
|
||||||
|
sync_operation.get("completed_at"),
|
||||||
|
sync_operation.get("success"),
|
||||||
|
sync_operation.get("accounts_processed", 0),
|
||||||
|
sync_operation.get("transactions_added", 0),
|
||||||
|
sync_operation.get("transactions_updated", 0),
|
||||||
|
sync_operation.get("balances_updated", 0),
|
||||||
|
sync_operation.get("duration_seconds"),
|
||||||
|
json.dumps(sync_operation.get("errors", [])),
|
||||||
|
json.dumps(sync_operation.get("logs", [])),
|
||||||
|
sync_operation.get("trigger_type", "manual"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
operation_id = cursor.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logger.debug(f"Persisted sync operation with ID: {operation_id}")
|
||||||
|
return operation_id
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to persist sync operation: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_sync_operations(self, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
|
||||||
|
"""Get sync operations from database"""
|
||||||
|
if not self.sqlite_enabled:
|
||||||
|
logger.warning("SQLite database disabled, cannot get sync operations")
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Get sync operations ordered by started_at descending
|
||||||
|
cursor.execute(
|
||||||
|
"""SELECT id, started_at, completed_at, success, accounts_processed,
|
||||||
|
transactions_added, transactions_updated, balances_updated,
|
||||||
|
duration_seconds, errors, logs, trigger_type
|
||||||
|
FROM sync_operations
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT ? OFFSET ?""",
|
||||||
|
(limit, offset),
|
||||||
|
)
|
||||||
|
|
||||||
|
operations = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
operation = {
|
||||||
|
"id": row[0],
|
||||||
|
"started_at": row[1],
|
||||||
|
"completed_at": row[2],
|
||||||
|
"success": bool(row[3]) if row[3] is not None else None,
|
||||||
|
"accounts_processed": row[4],
|
||||||
|
"transactions_added": row[5],
|
||||||
|
"transactions_updated": row[6],
|
||||||
|
"balances_updated": row[7],
|
||||||
|
"duration_seconds": row[8],
|
||||||
|
"errors": json.loads(row[9]) if row[9] else [],
|
||||||
|
"logs": json.loads(row[10]) if row[10] else [],
|
||||||
|
"trigger_type": row[11],
|
||||||
|
}
|
||||||
|
operations.append(operation)
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return operations
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get sync operations: {e}")
|
||||||
|
return []
|
||||||
@@ -20,7 +20,7 @@ class SyncService:
|
|||||||
"""Get current sync status"""
|
"""Get current sync status"""
|
||||||
return self._sync_status
|
return self._sync_status
|
||||||
|
|
||||||
async def sync_all_accounts(self, force: bool = False) -> SyncResult:
|
async def sync_all_accounts(self, force: bool = False, trigger_type: str = "manual") -> SyncResult:
|
||||||
"""Sync all connected accounts"""
|
"""Sync all connected accounts"""
|
||||||
if self._sync_status.is_running and not force:
|
if self._sync_status.is_running and not force:
|
||||||
raise Exception("Sync is already running")
|
raise Exception("Sync is already running")
|
||||||
@@ -34,9 +34,25 @@ class SyncService:
|
|||||||
transactions_updated = 0
|
transactions_updated = 0
|
||||||
balances_updated = 0
|
balances_updated = 0
|
||||||
errors = []
|
errors = []
|
||||||
|
logs = [f"Sync started at {start_time.isoformat()}"]
|
||||||
|
|
||||||
|
# Initialize sync operation record
|
||||||
|
sync_operation = {
|
||||||
|
"started_at": start_time.isoformat(),
|
||||||
|
"trigger_type": trigger_type,
|
||||||
|
"accounts_processed": 0,
|
||||||
|
"transactions_added": 0,
|
||||||
|
"transactions_updated": 0,
|
||||||
|
"balances_updated": 0,
|
||||||
|
"errors": [],
|
||||||
|
"logs": logs,
|
||||||
|
}
|
||||||
|
|
||||||
|
operation_id = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("Starting sync of all accounts")
|
logger.info("Starting sync of all accounts")
|
||||||
|
logs.append("Starting sync of all accounts")
|
||||||
|
|
||||||
# Get all requisitions and accounts
|
# Get all requisitions and accounts
|
||||||
requisitions = await self.gocardless.get_requisitions()
|
requisitions = await self.gocardless.get_requisitions()
|
||||||
@@ -46,6 +62,7 @@ class SyncService:
|
|||||||
all_accounts.update(req.get("accounts", []))
|
all_accounts.update(req.get("accounts", []))
|
||||||
|
|
||||||
self._sync_status.total_accounts = len(all_accounts)
|
self._sync_status.total_accounts = len(all_accounts)
|
||||||
|
logs.append(f"Found {len(all_accounts)} accounts to sync")
|
||||||
|
|
||||||
# Process each account
|
# Process each account
|
||||||
for account_id in all_accounts:
|
for account_id in all_accounts:
|
||||||
@@ -118,17 +135,39 @@ class SyncService:
|
|||||||
self._sync_status.accounts_synced = accounts_processed
|
self._sync_status.accounts_synced = accounts_processed
|
||||||
|
|
||||||
logger.info(f"Synced account {account_id} successfully")
|
logger.info(f"Synced account {account_id} successfully")
|
||||||
|
logs.append(f"Synced account {account_id} successfully")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Failed to sync account {account_id}: {str(e)}"
|
error_msg = f"Failed to sync account {account_id}: {str(e)}"
|
||||||
errors.append(error_msg)
|
errors.append(error_msg)
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
|
logs.append(error_msg)
|
||||||
|
|
||||||
end_time = datetime.now()
|
end_time = datetime.now()
|
||||||
duration = (end_time - start_time).total_seconds()
|
duration = (end_time - start_time).total_seconds()
|
||||||
|
|
||||||
self._sync_status.last_sync = end_time
|
self._sync_status.last_sync = end_time
|
||||||
|
|
||||||
|
# Update sync operation with final results
|
||||||
|
sync_operation.update({
|
||||||
|
"completed_at": end_time.isoformat(),
|
||||||
|
"success": len(errors) == 0,
|
||||||
|
"accounts_processed": accounts_processed,
|
||||||
|
"transactions_added": transactions_added,
|
||||||
|
"transactions_updated": transactions_updated,
|
||||||
|
"balances_updated": balances_updated,
|
||||||
|
"duration_seconds": duration,
|
||||||
|
"errors": errors,
|
||||||
|
"logs": logs,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Persist sync operation to database
|
||||||
|
try:
|
||||||
|
operation_id = await self.database.persist_sync_operation(sync_operation)
|
||||||
|
logger.debug(f"Saved sync operation with ID: {operation_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to persist sync operation: {e}")
|
||||||
|
|
||||||
result = SyncResult(
|
result = SyncResult(
|
||||||
success=len(errors) == 0,
|
success=len(errors) == 0,
|
||||||
accounts_processed=accounts_processed,
|
accounts_processed=accounts_processed,
|
||||||
@@ -144,44 +183,57 @@ class SyncService:
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"Sync completed: {accounts_processed} accounts, {transactions_added} new transactions"
|
f"Sync completed: {accounts_processed} accounts, {transactions_added} new transactions"
|
||||||
)
|
)
|
||||||
|
logs.append(f"Sync completed: {accounts_processed} accounts, {transactions_added} new transactions")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Sync failed: {str(e)}"
|
error_msg = f"Sync failed: {str(e)}"
|
||||||
errors.append(error_msg)
|
errors.append(error_msg)
|
||||||
|
logs.append(error_msg)
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
|
|
||||||
|
# Save failed sync operation
|
||||||
|
end_time = datetime.now()
|
||||||
|
duration = (end_time - start_time).total_seconds()
|
||||||
|
sync_operation.update({
|
||||||
|
"completed_at": end_time.isoformat(),
|
||||||
|
"success": False,
|
||||||
|
"accounts_processed": accounts_processed,
|
||||||
|
"transactions_added": transactions_added,
|
||||||
|
"transactions_updated": transactions_updated,
|
||||||
|
"balances_updated": balances_updated,
|
||||||
|
"duration_seconds": duration,
|
||||||
|
"errors": errors,
|
||||||
|
"logs": logs,
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
operation_id = await self.database.persist_sync_operation(sync_operation)
|
||||||
|
logger.debug(f"Saved failed sync operation with ID: {operation_id}")
|
||||||
|
except Exception as persist_error:
|
||||||
|
logger.error(f"Failed to persist failed sync operation: {persist_error}")
|
||||||
|
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
self._sync_status.is_running = False
|
self._sync_status.is_running = False
|
||||||
|
|
||||||
async def sync_specific_accounts(
|
async def sync_specific_accounts(
|
||||||
self, account_ids: List[str], force: bool = False
|
self, account_ids: List[str], force: bool = False, trigger_type: str = "manual"
|
||||||
) -> SyncResult:
|
) -> SyncResult:
|
||||||
"""Sync specific accounts"""
|
"""Sync specific accounts"""
|
||||||
if self._sync_status.is_running and not force:
|
if self._sync_status.is_running and not force:
|
||||||
raise Exception("Sync is already running")
|
raise Exception("Sync is already running")
|
||||||
|
|
||||||
# Similar implementation but only for specified accounts
|
|
||||||
# For brevity, implementing a simplified version
|
|
||||||
start_time = datetime.now()
|
|
||||||
self._sync_status.is_running = True
|
self._sync_status.is_running = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Process only specified accounts
|
# For now, delegate to sync_all_accounts but with specific filtering
|
||||||
# Implementation would be similar to sync_all_accounts
|
# This could be optimized later to only process specified accounts
|
||||||
# but filtered to only the specified account_ids
|
result = await self.sync_all_accounts(force=force, trigger_type=trigger_type)
|
||||||
|
|
||||||
|
# Filter results to only specified accounts if needed
|
||||||
|
# For simplicity, we'll return the full result for now
|
||||||
|
return result
|
||||||
|
|
||||||
end_time = datetime.now()
|
|
||||||
return SyncResult(
|
|
||||||
success=True,
|
|
||||||
accounts_processed=len(account_ids),
|
|
||||||
transactions_added=0,
|
|
||||||
transactions_updated=0,
|
|
||||||
balances_updated=0,
|
|
||||||
duration_seconds=(end_time - start_time).total_seconds(),
|
|
||||||
errors=[],
|
|
||||||
started_at=start_time,
|
|
||||||
completed_at=end_time,
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
self._sync_status.is_running = False
|
self._sync_status.is_running = False
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "leggen"
|
name = "leggen"
|
||||||
version = "2025.9.16"
|
version = "2025.9.18"
|
||||||
description = "An Open Banking CLI"
|
description = "An Open Banking CLI"
|
||||||
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
|
||||||
requires-python = "~=3.13.0"
|
requires-python = "~=3.13.0"
|
||||||
|
|||||||
@@ -324,6 +324,8 @@ class TestDatabaseService:
|
|||||||
with patch("sqlite3.connect") as mock_connect:
|
with patch("sqlite3.connect") as mock_connect:
|
||||||
mock_conn = mock_connect.return_value
|
mock_conn = mock_connect.return_value
|
||||||
mock_cursor = mock_conn.cursor.return_value
|
mock_cursor = mock_conn.cursor.return_value
|
||||||
|
# Mock fetchone to return (0,) indicating transaction doesn't exist yet
|
||||||
|
mock_cursor.fetchone.return_value = (0,)
|
||||||
|
|
||||||
result = await database_service._persist_transactions_sqlite(
|
result = await database_service._persist_transactions_sqlite(
|
||||||
"test-account-123", sample_transactions_db_format
|
"test-account-123", sample_transactions_db_format
|
||||||
@@ -338,6 +340,29 @@ class TestDatabaseService:
|
|||||||
mock_conn.commit.assert_called_once()
|
mock_conn.commit.assert_called_once()
|
||||||
mock_conn.close.assert_called_once()
|
mock_conn.close.assert_called_once()
|
||||||
|
|
||||||
|
async def test_persist_transactions_sqlite_duplicate_detection(
|
||||||
|
self, database_service, sample_transactions_db_format
|
||||||
|
):
|
||||||
|
"""Test that existing transactions are not returned as new."""
|
||||||
|
with patch("sqlite3.connect") as mock_connect:
|
||||||
|
mock_conn = mock_connect.return_value
|
||||||
|
mock_cursor = mock_conn.cursor.return_value
|
||||||
|
# Mock fetchone to return (1,) indicating transaction already exists
|
||||||
|
mock_cursor.fetchone.return_value = (1,)
|
||||||
|
|
||||||
|
result = await database_service._persist_transactions_sqlite(
|
||||||
|
"test-account-123", sample_transactions_db_format
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should return empty list since all transactions already exist
|
||||||
|
assert len(result) == 0
|
||||||
|
|
||||||
|
# Verify database operations still happened (INSERT OR REPLACE executed)
|
||||||
|
mock_connect.assert_called()
|
||||||
|
mock_cursor.execute.assert_called()
|
||||||
|
mock_conn.commit.assert_called_once()
|
||||||
|
mock_conn.close.assert_called_once()
|
||||||
|
|
||||||
async def test_persist_transactions_sqlite_error(self, database_service):
|
async def test_persist_transactions_sqlite_error(self, database_service):
|
||||||
"""Test handling error during transaction persistence."""
|
"""Test handling error during transaction persistence."""
|
||||||
with patch("sqlite3.connect") as mock_connect:
|
with patch("sqlite3.connect") as mock_connect:
|
||||||
|
|||||||
2
uv.lock
generated
2
uv.lock
generated
@@ -220,7 +220,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "leggen"
|
name = "leggen"
|
||||||
version = "2025.9.16"
|
version = "2025.9.18"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "apscheduler" },
|
{ name = "apscheduler" },
|
||||||
|
|||||||
Reference in New Issue
Block a user