commit 87789fa82d8534410681bd77118ece3164caed83 Author: Denozordec Date: Sat Jun 13 16:43:52 2026 +0700 INIT COMMIT diff --git a/.cursor/rules/ai-coding-assistant.mdc b/.cursor/rules/ai-coding-assistant.mdc new file mode 100644 index 0000000..edfa468 --- /dev/null +++ b/.cursor/rules/ai-coding-assistant.mdc @@ -0,0 +1,56 @@ +--- +description: Concise AI assistant — clean code, token efficiency, codebase alignment +alwaysApply: true +--- + +# AI Coding Assistant + +You work inside a real codebase. Be precise, concise, and aligned with existing patterns. + +## Clean Code + +- Minimal, readable, maintainable code; simple over clever +- Meaningful names; DRY; small single-responsibility functions +- Follow existing project style and patterns + +## Token Efficiency + +- Do not explain obvious things +- No step-by-step reasoning unless explicitly asked +- Output only what is necessary: code, brief comments when needed +- No long prose, summaries, or repetition +- If unsure — ask a short clarifying question instead of guessing + +## Work With Existing Codebase + +- Analyze surrounding code before generating new code +- Reuse existing utilities, helpers, and patterns +- Do not reinvent functionality already in the project +- Respect project architecture + +## Documentation Awareness + +- Check project docs, README, comments, and types before implementing +- If behavior is unclear: infer from types/tests/examples, or ask +- Prefer documented approaches over assumptions + +## Output Format + +- Default: only code +- If explanation is required — keep it under 3–5 lines +- Highlight only important decisions + +## Refactoring + +- Preserve behavior unless told otherwise +- Improve readability and structure; reduce complexity and duplication + +## Debugging + +- Identify root cause, not symptoms +- Suggest minimal fix; avoid rewriting large parts unless necessary + +## Missing Context + +- Ask concise, targeted questions +- Do not hallucinate APIs or project structure diff --git a/.cursor/rules/cloudflare-best-practices.mdc b/.cursor/rules/cloudflare-best-practices.mdc new file mode 100644 index 0000000..e42ebbf --- /dev/null +++ b/.cursor/rules/cloudflare-best-practices.mdc @@ -0,0 +1,238 @@ +--- +description: Definitive guidelines for building secure, performant, and maintainable applications on Cloudflare's developer platform, emphasizing tiny bundles, edge-first design, and robust security. +globs: **/* +alwaysApply: false +--- + +# Cloudflare Best Practices + +Cloudflare's developer platform thrives on speed, security, and global distribution. Our focus is on building tiny, observable, and secure bundles that leverage the edge. This guide outlines the definitive best practices for our team. + +## 1. Code Organization and Structure + +**Prioritize Workers & Pages for all new applications.** Use the official `wrangler` CLI for project scaffolding and deployment. + +* **Project Initialization:** + * ✅ GOOD: Always start with `wrangler generate`. + ```bash + npx wrangler generate my-worker --type=module --ts + # For full-stack apps with Pages: + npx create-cloudflare@latest my-fullstack-app --framework next + ``` +* **Monorepos:** For projects with multiple Workers or Pages apps, use `pnpm` or `yarn workspaces`. + * ✅ GOOD: Centralize dependencies and build scripts. + ```json + // package.json + { + "workspaces": ["apps/*", "packages/*"] + } + ``` +* **Environment Variables:** Manage secrets securely via `wrangler.toml` or the Cloudflare dashboard. Use `.dev.vars` for local development. + * ❌ BAD: Storing sensitive data directly in code or `.env` files that aren't `.dev.vars`. + * ✅ GOOD: Define variables in `wrangler.toml` and reference them in your Worker. + ```toml + # wrangler.toml + name = "my-worker" + main = "src/index.ts" + compatibility_date = "2025-12-01" + + [vars] + MY_API_KEY = "your-api-key-value" # For non-sensitive defaults or local testing + + [secrets] # For sensitive production secrets + # MY_DB_URL = "set via `wrangler secret put MY_DB_URL`" + ``` + * ✅ GOOD: Use `.dev.vars` for local secrets, and integrate with CLI tools. + ```bash + # .dev.vars + DATABASE_URL="postgresql://user:pass@localhost:5432/mydb" + # Use with Prisma: + dotenv -e .dev.vars -- npx prisma migrate dev + ``` + +## 2. Common Patterns and Anti-patterns + +**Design for the edge: stateless Workers, stateful Durable Objects.** + +* **Edge-first Design:** Workers are stateless and globally distributed. Avoid in-memory state that needs to persist across requests or instances. + * ❌ BAD: Relying on global variables for user sessions or shared data. + ```typescript + let requestCount = 0; // This will reset or be inconsistent + export default { + async fetch(request, env, ctx) { + requestCount++; + return new Response(`Requests: ${requestCount}`); + }, + }; + ``` + * ✅ GOOD: Use KV for simple key-value storage, D1 for relational data, or Durable Objects for strong consistency and real-time state. + ```typescript + // Using KV for simple counters + export default { + async fetch(request, env, ctx) { + let count = parseInt(await env.REQUEST_COUNTER.get("total_requests") || "0"); + await env.REQUEST_COUNTER.put("total_requests", String(count + 1)); + return new Response(`Requests: ${count + 1}`); + }, + }; + ``` +* **Serverless SQL (D1, Hyperdrive):** Use D1 for new, natively serverless relational databases. For existing regional databases, use Hyperdrive for edge acceleration. + * ✅ GOOD: D1 for new projects. + ```typescript + // src/index.ts + import { DrizzleD1Database, drizzle } from 'drizzle-orm/d1'; + interface Env { D1_BINDING: D1Database; } + + export default { + async fetch(request: Request, env: Env) { + const db = drizzle(env.D1_BINDING); + const result = await db.select().from(users).all(); + return new Response(JSON.stringify(result)); + }, + }; + ``` +* **Object Storage (R2):** Always use R2 for large file storage. Benefit from zero egress fees and global distribution. + * ✅ GOOD: Efficiently store and retrieve assets. + ```typescript + // src/index.ts + interface Env { R2_BUCKET: R2Bucket; } + export default { + async fetch(request: Request, env: Env) { + const object = await env.R2_BUCKET.get("my-file.txt"); + if (object) return new Response(object.body); + return new Response("Not found", { status: 404 }); + }, + }; + ``` +* **Cloudflare One & Tunnels:** Securely expose internal services without opening firewall ports. Integrate SSO for private apps. + * ✅ GOOD: Use `cloudflared` for secure access to Azure, AWS, GCP, or on-prem resources. + ```bash + # On your private VM + cloudflared tunnel create Azure-01 + # Configure config.yml and start service + ``` + * ✅ GOOD: Present private web apps on Cloudflare-owned domains with SSO. + ```yaml + # Cloudflare Access Policy (example) + name: "Require SSO for Internal App" + application: "internal-app.cloudflare.com" + rules: + - action: "allow" + identity_provider_ids: ["your-sso-provider-id"] + emails: ["@your-domain.com"] + ``` + +## 3. Performance Considerations + +**Bundle size and cold start latency are paramount.** + +* **Bundle Size Optimization:** Keep Worker bundles as small as possible. Tree-shake dependencies aggressively. + * ❌ BAD: Importing large, unoptimized libraries. + * ✅ GOOD: Use `esbuild` for bundling and ensure minimal imports. + ```typescript + // Example: Import only specific functions + import { get } from 'lodash-es/get'; // Instead of import * as _ from 'lodash'; + ``` +* **Prisma Optimization:** For ORMs, use Prisma with `engineType: "client"` and an edge-compatible driver adapter. This avoids large Rust binaries. + * ❌ BAD: Default Prisma setup with Rust query engines in Workers. + * ✅ GOOD: Configure `schema.prisma` and use an adapter. + ```prisma + // schema.prisma + generator client { + provider = "prisma-client-js" + engineType = "client" # Crucial for Workers + } + datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + } + ``` + ```typescript + // src/index.ts + import { PrismaClient } from '@prisma/client/edge'; + import { withAccelerate } from '@prisma/extension-accelerate'; // For Prisma Accelerate + + const prisma = new PrismaClient({ + datasourceUrl: env.DATABASE_URL, + }).$extends(withAccelerate()); // If using Accelerate + ``` +* **Caching:** Leverage Cloudflare's CDN for static assets and API responses. + * ✅ GOOD: Set appropriate `Cache-Control` headers. + ```typescript + return new Response(JSON.stringify(data), { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=3600, s-maxage=86400', + }, + }); + ``` + +## 4. Common Pitfalls and Gotchas + +**Understand the Workers runtime: single-threaded, event-driven, no Node.js APIs.** + +* **Blocking I/O:** Workers are single-threaded and event-driven. All I/O operations *must* be `await`ed. + * ❌ BAD: Synchronous file system operations or long-running CPU-bound tasks. + * ✅ GOOD: Asynchronous operations for network requests, KV, D1, etc. + ```typescript + // All I/O should be awaited + const response = await fetch('https://api.example.com'); + const data = await env.KV_NAMESPACE.get('key'); + ``` +* **Missing Security Policies:** Always configure WAF, Bot Fight Mode, and Rate Limiting. + * ❌ BAD: Deploying public APIs without edge security. + * ✅ GOOD: Implement granular rate limiting for login endpoints, API abuse, and `cf_clearance` cookie reuse. + ```yaml + # Example Rate Limiting Rule (conceptual, configured in Cloudflare Dashboard) + # Match: (http.request.uri.path eq "/login" and http.request.method eq "POST") + # Counting characteristics: IP + # Rate: 4 requests / 1 minute + # Action: Managed Challenge + ``` + +## 5. Testing Approaches + +**Test early, test often, and test at the edge.** + +* **Unit Testing:** Use standard JavaScript/TypeScript testing frameworks (`Vitest`, `Jest`) for pure functions and business logic. + * ✅ GOOD: Isolate and test small components. + ```typescript + // my-logic.test.ts + import { expect, test } from 'vitest'; + import { calculateDiscount } from './my-logic'; + test('calculates discount correctly', () => { + expect(calculateDiscount(100, 0.1)).toBe(90); + }); + ``` +* **Integration Testing (Miniflare):** Use `Miniflare` for local emulation of the Workers runtime, including bindings (KV, D1, R2). This is critical for testing Worker logic with dependencies. + * ✅ GOOD: Simulate the Cloudflare environment locally. + ```typescript + // worker.test.ts + import { Miniflare } from 'miniflare'; + import { test, expect, beforeAll, afterAll } from 'vitest'; + + let mf: Miniflare; + beforeAll(async () => { + mf = new Miniflare({ + modules: true, + scriptPath: 'dist/index.mjs', // Your compiled worker + bindings: { MY_VAR: 'test-value' }, + kvNamespaces: ['REQUEST_COUNTER'], + d1Databases: ['D1_BINDING'], + r2Buckets: ['R2_BUCKET'], + }); + }); + afterAll(async () => await mf.dispose()); + + test('Worker responds with correct text', async () => { + const res = await mf.dispatchFetch('http://localhost/'); + expect(await res.text()).toBe('Hello World!'); + }); + ``` +* **End-to-End Testing:** Deploy to a staging environment (`wrangler deploy --env staging`) as part of your CI/CD pipeline. Use tools like Playwright or Cypress to verify full application flows. + * ✅ GOOD: Validate the entire stack in a production-like environment. + ```bash + # CI/CD step + npx wrangler deploy --env staging + npx playwright test --project=staging + ``` diff --git a/.cursor/rules/code-guidelines-cursorrules-prompt-file.mdc b/.cursor/rules/code-guidelines-cursorrules-prompt-file.mdc new file mode 100644 index 0000000..f7c58f6 --- /dev/null +++ b/.cursor/rules/code-guidelines-cursorrules-prompt-file.mdc @@ -0,0 +1,56 @@ +--- +description: "Cursor rules for code development with guidelines integration." +globs: **/* +alwaysApply: false +--- +1. **Verify Information**: Always verify information before presenting it. Do not make assumptions or speculate without clear evidence. + +2. **File-by-File Changes**: Make changes file by file and give me a chance to spot mistakes. + +3. **No Apologies**: Never use apologies. + +4. **No Understanding Feedback**: Avoid giving feedback about understanding in comments or documentation. + +5. **No Whitespace Suggestions**: Don't suggest whitespace changes. + +6. **No Summaries**: Don't summarize changes made. + +7. **No Inventions**: Don't invent changes other than what's explicitly requested. + +8. **No Unnecessary Confirmations**: Don't ask for confirmation of information already provided in the context. + +9. **Preserve Existing Code**: Don't remove unrelated code or functionalities. Pay attention to preserving existing structures. + +10. **Single Chunk Edits**: Provide all edits in a single chunk instead of multiple-step instructions or explanations for the same file. + +11. **No Implementation Checks**: Don't ask the user to verify implementations that are visible in the provided context. + +12. **No Unnecessary Updates**: Don't suggest updates or changes to files when there are no actual modifications needed. + +13. **Provide Real File Links**: Always provide links to the real files, not the context generated file. + +14. **No Current Implementation**: Don't show or discuss the current implementation unless specifically requested. + +15. **Check Context Generated File Content**: Remember to check the context generated file for the current file contents and implementations. + +16. **Use Explicit Variable Names**: Prefer descriptive, explicit variable names over short, ambiguous ones to enhance code readability. + +17. **Follow Consistent Coding Style**: Adhere to the existing coding style in the project for consistency. + +18. **Prioritize Performance**: When suggesting changes, consider and prioritize code performance where applicable. + +19. **Security-First Approach**: Always consider security implications when modifying or suggesting code changes. + +20. **Test Coverage**: Suggest or include appropriate unit tests for new or modified code. + +21. **Error Handling**: Implement robust error handling and logging where necessary. + +22. **Modular Design**: Encourage modular design principles to improve code maintainability and reusability. + +23. **Version Compatibility**: Ensure suggested changes are compatible with the project's specified language or framework versions. + +24. **Avoid Magic Numbers**: Replace hardcoded values with named constants to improve code clarity and maintainability. + +25. **Consider Edge Cases**: When implementing logic, always consider and handle potential edge cases. + +26. **Use Assertions**: Include assertions wherever possible to validate assumptions and catch potential errors early. diff --git a/.cursor/rules/code-quality-guidelines.mdc b/.cursor/rules/code-quality-guidelines.mdc new file mode 100644 index 0000000..5186a8d --- /dev/null +++ b/.cursor/rules/code-quality-guidelines.mdc @@ -0,0 +1,49 @@ +--- +description: Code Quality Guidelines +globs: **/* +alwaysApply: false +--- + +# Code Quality Guidelines + +## Verify Information +Always verify information before presenting it. Do not make assumptions or speculate without clear evidence. + +## File-by-File Changes +Make changes file by file and give me a chance to spot mistakes. + +## No Apologies +Never use apologies. + +## No Understanding Feedback +Avoid giving feedback about understanding in comments or documentation. + +## No Whitespace Suggestions +Don't suggest whitespace changes. + +## No Summaries +Don't summarize changes made. + +## No Inventions +Don't invent changes other than what's explicitly requested. + +## No Unnecessary Confirmations +Don't ask for confirmation of information already provided in the context. + +## Preserve Existing Code +Don't remove unrelated code or functionalities. Pay attention to preserving existing structures. + +## Single Chunk Edits +Provide all edits in a single chunk instead of multiple-step instructions or explanations for the same file. + +## No Implementation Checks +Don't ask the user to verify implementations that are visible in the provided context. + +## No Unnecessary Updates +Don't suggest updates or changes to files when there are no actual modifications needed. + +## Provide Real File Links +Always provide links to the real files, not x.md. + +## No Current Implementation +Don't show or discuss the current implementation unless specifically requested. diff --git a/.cursor/rules/codequality.mdc b/.cursor/rules/codequality.mdc new file mode 100644 index 0000000..7d54e8c --- /dev/null +++ b/.cursor/rules/codequality.mdc @@ -0,0 +1,48 @@ +--- +description: Code Quality Guidelines +globs: ["**/*"] +alwaysApply: false +--- +# Code Quality Guidelines + +## Verify Information +Always verify information before presenting it. Do not make assumptions or speculate without clear evidence. + +## File-by-File Changes +Make changes file by file and give me a chance to spot mistakes. + +## No Apologies +Never use apologies. + +## No Understanding Feedback +Avoid giving feedback about understanding in comments or documentation. + +## No Whitespace Suggestions +Don't suggest whitespace changes. + +## No Summaries +Don't summarize changes made. + +## No Inventions +Don't invent changes other than what's explicitly requested. + +## No Unnecessary Confirmations +Don't ask for confirmation of information already provided in the context. + +## Preserve Existing Code +Don't remove unrelated code or functionalities. Pay attention to preserving existing structures. + +## Single Chunk Edits +Provide all edits in a single chunk instead of multiple-step instructions or explanations for the same file. + +## No Implementation Checks +Don't ask the user to verify implementations that are visible in the provided context. + +## No Unnecessary Updates +Don't suggest updates or changes to files when there are no actual modifications needed. + +## Provide Real File Links +Always provide links to the real files, not x.md. + +## No Current Implementation +Don't show or discuss the current implementation unless specifically requested. diff --git a/.cursor/rules/cursor-ai-react-typescript-shadcn-ui-cursorrules-p.mdc b/.cursor/rules/cursor-ai-react-typescript-shadcn-ui-cursorrules-p.mdc new file mode 100644 index 0000000..184d7dc --- /dev/null +++ b/.cursor/rules/cursor-ai-react-typescript-shadcn-ui-cursorrules-p.mdc @@ -0,0 +1,24 @@ +--- +description: "Cursor rules for Cursor AI development with React, TypeScript, and shadcn/ui integration." +globs: **/* +alwaysApply: false +--- +You are an expert AI programming assistant that primarily focuses on producing clear, readable React and TypeScript code. + +You always use the latest stable version of TypeScript, JavaScript, React, Node.js, Next.js App Router, Shadcn UI, Tailwind CSS and you are familiar with the latest features and best practices. + +You carefully provide accurate, factual, thoughtful answers, and are a genius at reasoning AI to chat, to generate code. + +Style and Structure + +Naming Conventions + +TypeScript Usage + +UI and Styling + +Performance Optimization + +Other Rules need to follow: + +Don't be lazy, write all the code to implement features I ask for. \ No newline at end of file diff --git a/.cursor/rules/front-end-cursor-rules.mdc b/.cursor/rules/front-end-cursor-rules.mdc new file mode 100644 index 0000000..f6c5a27 --- /dev/null +++ b/.cursor/rules/front-end-cursor-rules.mdc @@ -0,0 +1,50 @@ +--- +alwaysApply: true +--- + +You are a Senior Front-End Developer and an Expert in ReactJS, NextJS, JavaScript, TypeScript, HTML, CSS and modern UI/UX frameworks (e.g., TailwindCSS, Shadcn, Radix). You are thoughtful, give nuanced answers, and are brilliant at reasoning. You carefully provide accurate, factual, thoughtful answers, and are a genius at reasoning. + +- Follow the user’s requirements carefully & to the letter. +- First think step-by-step - describe your plan for what to build in pseudocode, written out in great detail. +- Confirm, then write code! +- Always write correct, best practice, DRY principle (Dont Repeat Yourself), bug free, fully functional and working code also it should be aligned to listed rules down below at Code Implementation Guidelines . +- Focus on easy and readability code, over being performant. +- Fully implement all requested functionality. +- Leave NO todo’s, placeholders or missing pieces. +- Ensure code is complete! Verify thoroughly finalised. +- Include all required imports, and ensure proper naming of key components. +- Be concise Minimize any other prose. +- If you think there might not be a correct answer, you say so. +- If you do not know the answer, say so, instead of guessing. + +### Coding Environment +The user asks questions about the following coding languages: +- ReactJS +- NextJS +- JavaScript +- TypeScript +- TailwindCSS +- HTML +- CSS + +### Code Implementation Guidelines +Follow these rules when you write code: +- Use early returns whenever possible to make the code more readable. +- Always use Tailwind classes for styling HTML elements; avoid using CSS or tags. +- Use “class:” instead of the tertiary operator in class tags whenever possible. +- Use descriptive variable and function/const names. Also, event functions should be named with a “handle” prefix, like “handleClick” for onClick and “handleKeyDown” for onKeyDown. +- Implement accessibility features on elements. For example, a tag should have a tabindex=“0”, aria-label, on:click, and on:keydown, and similar attributes. +- Use consts instead of functions, for example, “const toggle = () =>”. Also, define a type if possible. +- Don't use semicolons. + +### Generate Commit Guidelines +- The commit contains the following structural elements, to communicate intent to the consumers of your library: + - fix: a commit of the type `fix` patches a bug in your codebase (this correlates with PATCH in semantic versioning). + - feat: a commit of the type `feat` introduces a new feature to the codebase (this correlates with MINOR in semantic versioning). + - Others: commit types other than `fix:` and `feat:` are allowed, for example `chore:`, `docs:`, `style:`, `refactor:`, `perf:`, `test:`, and others. + - A scope may be provided to a commit’s type, to provide additional contextual information and is contained within parenthesis, e.g., `feat(parser): add ability to parse arrays`. +- Commit messages should be written in the following format: + - Do not end the subject line with a period. + - Use the imperative mood in the subject line. + - Use the body to explain what and why you have done something. In most cases, you can leave out details about how a change has been made. + - The commit message should be structured as follows: `[optional scope]: ` diff --git a/.cursor/rules/fullstack-engineering-standards.mdc b/.cursor/rules/fullstack-engineering-standards.mdc new file mode 100644 index 0000000..1c737df --- /dev/null +++ b/.cursor/rules/fullstack-engineering-standards.mdc @@ -0,0 +1,56 @@ +--- +description: Cursor rules for TypeScript, React, Node.js, clean architecture, testing, and WHY-oriented engineering guidance. +globs: **/* +alwaysApply: false +--- + +# Full-Stack Engineering Standards + +You are a senior full-stack developer specializing in TypeScript, React, and Node.js. +Every rule includes a WHY explanation for the reasoning behind it. + +## Coding Standards + +- Use strict TypeScript. Never use `any`. Use `unknown` for dynamic data. + > WHY: Type safety prevents runtime errors and improves developer experience. +- Max function length: 20 lines. Extract helpers for complex logic. + > WHY: Improves testability, readability, and makes code review easier. +- Naming: camelCase for variables/functions, PascalCase for classes/interfaces, UPPER_SNAKE for constants. + > WHY: Consistent with TypeScript ecosystem standards. +- Prefer interfaces over type aliases for objects. + > WHY: Interfaces are extendable and produce better error messages. + +## Architecture + +- Clean Architecture with dependency inversion. Domain layer is framework-agnostic. + > WHY: Testable business logic that survives framework changes. +- Repository pattern for data access. Never call ORM directly from business logic. + > WHY: Decouples persistence from domain, enables testing with in-memory implementations. +- React Query for server state, Zustand for client state. No Redux. + > WHY: Lighter weight, better TypeScript support, less boilerplate. + +## Error Handling + +- Custom AppError hierarchy with HTTP status codes. Throw for exceptional, return Result for expected failures. + > WHY: Clear intent — callers know which errors to catch vs handle. +- Structured logging with Winston. Never log sensitive data (passwords, tokens, PII). + > WHY: Observability without security risk. Structured logs enable alerting. + +## Testing + +- 80% unit coverage, 100% critical paths. Use factory functions for test data. + > WHY: Factory functions are maintainable and composable. Fixtures become stale. +- Mock only external dependencies (APIs, DB). Never mock internal logic. + > WHY: Tests should reflect reality. Over-mocking hides real bugs. + +## Security + +- Validate all input with Zod schemas at API boundaries. + > WHY: Runtime validation catches what TypeScript can't — malformed external data. +- Rate limit all public endpoints. Use helmet middleware. + > WHY: Defense in depth against abuse and common web vulnerabilities. + +## Git + +- Max 400 lines per PR. Conventional commits: feat/fix/refactor/test/docs. + > WHY: Small PRs get reviewed faster and have fewer bugs. diff --git a/.cursor/rules/gitflow.mdc b/.cursor/rules/gitflow.mdc new file mode 100644 index 0000000..2f2025e --- /dev/null +++ b/.cursor/rules/gitflow.mdc @@ -0,0 +1,113 @@ +--- +description: Gitflow Workflow Rules. These rules should be applied when performing git operations. +globs: ["**/*"] +alwaysApply: false +--- +# Gitflow Workflow Rules + +## Main Branches + +### main (or master) +- Contains production-ready code +- Never commit directly to main +- Only accepts merges from: + - hotfix/* branches + - release/* branches +- Must be tagged with version number after each merge + +### develop +- Main development branch +- Contains latest delivered development changes +- Source branch for feature branches +- Never commit directly to develop + +## Supporting Branches + +### feature/* +- Branch from: develop +- Merge back into: develop +- Naming convention: feature/[issue-id]-descriptive-name +- Example: feature/123-user-authentication +- Must be up-to-date with develop before creating PR +- Delete after merge + +### release/* +- Branch from: develop +- Merge back into: + - main + - develop +- Naming convention: release/vX.Y.Z +- Example: release/v1.2.0 +- Only bug fixes, documentation, and release-oriented tasks +- No new features +- Delete after merge + +### hotfix/* +- Branch from: main +- Merge back into: + - main + - develop +- Naming convention: hotfix/vX.Y.Z +- Example: hotfix/v1.2.1 +- Only for urgent production fixes +- Delete after merge + +## Commit Messages + +- Format: `type(scope): description` +- Types: + - feat: New feature + - fix: Bug fix + - docs: Documentation changes + - style: Formatting, missing semicolons, etc. + - refactor: Code refactoring + - test: Adding tests + - chore: Maintenance tasks + +## Version Control + +### Semantic Versioning +- MAJOR version for incompatible API changes +- MINOR version for backwards-compatible functionality +- PATCH version for backwards-compatible bug fixes + +## Pull Request Rules + +1. All changes must go through Pull Requests +2. Required approvals: minimum 1 +3. CI checks must pass +4. No direct commits to protected branches (main, develop) +5. Branch must be up to date before merging +6. Delete branch after merge + +## Branch Protection Rules + +### main & develop +- Require pull request reviews +- Require status checks to pass +- Require branches to be up to date +- Include administrators in restrictions +- No force pushes +- No deletions + +## Release Process + +1. Create release branch from develop +2. Bump version numbers +3. Fix any release-specific issues +4. Create PR to main +5. After merge to main: + - Tag release + - Merge back to develop + - Delete release branch + +## Hotfix Process + +1. Create hotfix branch from main +2. Fix the issue +3. Bump patch version +4. Create PR to main +5. After merge to main: + - Tag release + - Merge back to develop + - Delete hotfix branch diff --git a/.cursor/rules/react-components-creation-cursorrules-prompt-file.mdc b/.cursor/rules/react-components-creation-cursorrules-prompt-file.mdc new file mode 100644 index 0000000..b903e8a --- /dev/null +++ b/.cursor/rules/react-components-creation-cursorrules-prompt-file.mdc @@ -0,0 +1,40 @@ +--- +description: "Cursor rules for React component creation and development." +globs: **/* +alwaysApply: false +--- +# Cursor Rules + +## Whenever you need a React component + +1. Carefully consider the component's purpose, functionality, and design + +2. Think slowly, step by step, and outline your reasoning + +3. Check if a similar component already exists in any of the following locations + 1. packages/ui/src/components + 2. apps/spa/src/components + +4. If it doesn't exist, generate a detailed prompt for the component, including: + - Component name and purpose + - Desired props and their types + - Any specific styling or behavior requirements + - Mention of using Tailwind CSS for styling + - Request for TypeScript usage + +5. URL encode the prompt. + +6. Create a clickable link in this format: + [ComponentName](https://v0.dev/chat?q={encoded_prompt}) + +7. After generating, adapt the component to fit our project structure: + - Import + - common shadcn/ui components from @repo/ui/components/ui/ + - app specific components from @/components + - Ensure it follows our existing component patterns + - Add any necessary custom logic or state management + +Example prompt template: +"Create a React component named {ComponentName} using TypeScript and Tailwind CSS. It should {description of functionality}. Props should include {list of props with types}. The component should {any specific styling or behavior notes}. Please provide the full component code." + +Remember to replace placeholders like and with the actual values used in your project. diff --git a/.cursor/rules/react-tanstack-router-query-cursorrules-prompt-file.mdc b/.cursor/rules/react-tanstack-router-query-cursorrules-prompt-file.mdc new file mode 100644 index 0000000..cadb118 --- /dev/null +++ b/.cursor/rules/react-tanstack-router-query-cursorrules-prompt-file.mdc @@ -0,0 +1,273 @@ +--- +description: "Cursor rules for React SPAs combining TanStack Router v1 and TanStack Query v5 for zero-loading-spinner routing and type-safe server state." +globs: **/* +alwaysApply: false +--- +You are an expert in React, TanStack Router v1, TanStack Query v5, TypeScript, Vite, and building fully type-safe single-page applications. + +# React + TanStack Router + TanStack Query Guidelines + +## Architecture Overview +- TanStack Router handles all routing, URL state, and navigation +- TanStack Query manages all server state, caching, and async data +- React components are pure UI — they read from Query cache and trigger mutations +- Loaders bridge Router and Query: they prefetch into the Query cache before render +- This eliminates loading spinners for route-level data; Suspense handles component-level loading + +## Project Setup +``` +src/ + routes/ + __root.tsx + index.tsx + posts/ + index.tsx + $postId.tsx + queries/ ← Query definitions (queryOptions factories) + posts.ts + users.ts + api/ ← API client functions (fetchers) + posts.ts + users.ts + lib/ + queryClient.ts + router.ts + main.tsx +``` + +## QueryClient + Router Setup +```ts +// src/lib/queryClient.ts +import { QueryClient } from '@tanstack/react-query' + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60, + retry: (count, error: any) => error?.status !== 404 && count < 2, + }, + }, +}) +``` + +```tsx +// src/lib/router.ts +import { createRouter } from '@tanstack/react-router' +import { routeTree } from '../routeTree.gen' +import { queryClient } from './queryClient' + +export const router = createRouter({ + routeTree, + context: { queryClient }, + defaultPreload: 'intent', + defaultPreloadStaleTime: 0, +}) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} +``` + +```tsx +// src/main.tsx +import { RouterProvider } from '@tanstack/react-router' +import { QueryClientProvider } from '@tanstack/react-query' +import { router } from './lib/router' +import { queryClient } from './lib/queryClient' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) +``` + +## Query Definitions (queryOptions factories) +- Co-locate query key, fetcher, and staleTime in one place +- Share between Router loaders and component hooks +```ts +// src/queries/posts.ts +import { queryOptions, infiniteQueryOptions } from '@tanstack/react-query' +import { fetchPost, fetchPosts } from '../api/posts' + +export const postKeys = { + all: ['posts'] as const, + lists: () => [...postKeys.all, 'list'] as const, + list: (filters?: PostFilters) => [...postKeys.lists(), filters] as const, + details: () => [...postKeys.all, 'detail'] as const, + detail: (id: string) => [...postKeys.details(), id] as const, +} + +export const postDetailQueryOptions = (id: string) => + queryOptions({ + queryKey: postKeys.detail(id), + queryFn: () => fetchPost(id), + staleTime: 1000 * 60 * 5, + }) + +export const postsListQueryOptions = (filters?: PostFilters) => + queryOptions({ + queryKey: postKeys.list(filters), + queryFn: () => fetchPosts(filters), + staleTime: 1000 * 60, + }) +``` + +## Router Loader + Query Integration +- Loaders call `queryClient.ensureQueryData` — populates cache, renders immediately without spinner +- Components then call `useQuery` with the same options — reads from cache synchronously +```tsx +// src/routes/posts/$postId.tsx +import { createFileRoute } from '@tanstack/react-router' +import { useQuery } from '@tanstack/react-query' +import { postDetailQueryOptions } from '../../queries/posts' + +export const Route = createFileRoute('/posts/$postId')({ + loader: ({ context: { queryClient }, params }) => + queryClient.ensureQueryData(postDetailQueryOptions(params.postId)), + + errorComponent: ({ error }) => , + pendingComponent: PostSkeleton, + component: PostDetail, +}) + +function PostDetail() { + const { postId } = Route.useParams() + // data is already in cache from loader — no loading state + const { data: post } = useQuery(postDetailQueryOptions(postId)) + + return

{post!.title}

+} +``` + +## Search Params + Query Integration +- Use TanStack Router search params as the source of truth for filter/pagination state +- Pass search params into queryOptions to drive query key and fetcher +```tsx +// src/routes/posts/index.tsx +import { createFileRoute, Link } from '@tanstack/react-router' +import { useQuery } from '@tanstack/react-query' +import { z } from 'zod' +import { postsListQueryOptions } from '../../queries/posts' + +const searchSchema = z.object({ + page: z.number().int().min(1).default(1), + category: z.string().optional(), +}) + +export const Route = createFileRoute('/posts/')({ + validateSearch: searchSchema, + loader: ({ context: { queryClient }, location: { search } }) => + queryClient.ensureQueryData(postsListQueryOptions(search)), + component: PostsList, +}) + +function PostsList() { + const search = Route.useSearch() + const navigate = Route.useNavigate() + const { data: posts } = useQuery(postsListQueryOptions(search)) + + return ( +
+ {posts?.map(post => ( + + {post.title} + + ))} + +
+ ) +} +``` + +## Mutations +```tsx +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useNavigate } from '@tanstack/react-router' +import { postKeys } from '../../queries/posts' + +function CreatePostForm() { + const queryClient = useQueryClient() + const navigate = useNavigate() + + const mutation = useMutation({ + mutationFn: createPost, + onSuccess: (newPost) => { + // Populate detail cache immediately + queryClient.setQueryData(postKeys.detail(newPost.id), newPost) + // Invalidate list queries + queryClient.invalidateQueries({ queryKey: postKeys.lists() }) + // Navigate to new post (no loading — cache is warm) + navigate({ to: '/posts/$postId', params: { postId: newPost.id } }) + }, + }) + + return (/* form JSX */) +} +``` + +## Authentication Pattern +```tsx +// src/routes/__root.tsx +import { createRootRouteWithContext } from '@tanstack/react-router' + +export interface RouterContext { + queryClient: QueryClient + auth: { isAuthenticated: boolean; user: User | null } +} + +export const Route = createRootRouteWithContext()({ + component: RootLayout, +}) + +// src/routes/_auth.tsx (pathless layout for protected routes) +export const Route = createFileRoute('/_auth')({ + beforeLoad: ({ context }) => { + if (!context.auth.isAuthenticated) { + throw redirect({ to: '/login', search: { redirect: location.pathname } }) + } + }, +}) +``` + +## Prefetching on Hover +```tsx +function PostCard({ post }: { post: Post }) { + const queryClient = useQueryClient() + return ( + queryClient.prefetchQuery(postDetailQueryOptions(post.id))} + > + {post.title} + + ) +} +``` + +## DevTools (Development Only) +```tsx +// In __root.tsx +import { TanStackRouterDevtools } from '@tanstack/router-devtools' +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' + +// Inside component +{import.meta.env.DEV && ( + <> + + + +)} +``` + +## Key Rules +- Always define `queryOptions` outside of components — not inline in `useQuery()` +- Never use `useEffect` to fetch data — use loaders or `useQuery` +- Always type router context — `declare module '@tanstack/react-router'` registration is required +- Search params are the only source of truth for URL-driven filter state +- Mutations should `setQueryData` + `invalidateQueries`, not just invalidate, for instant UI feedback diff --git a/.cursor/rules/react-tanstack-router-query.mdc b/.cursor/rules/react-tanstack-router-query.mdc new file mode 100644 index 0000000..c60dd9d --- /dev/null +++ b/.cursor/rules/react-tanstack-router-query.mdc @@ -0,0 +1,116 @@ +--- +description: "React SPA with TanStack Router v1 + TanStack Query v5 — the definitive pattern for zero-loading-spinner routing, type-safe URLs, and cache-first data" +globs: ["src/routes/**/*", "src/queries/**/*", "src/lib/router.ts", "src/lib/queryClient.ts"] +alwaysApply: false +--- +You are an expert in React, TanStack Router v1, TanStack Query v5, TypeScript, and Vite. + +## Architecture +- TanStack Router: routing, URL state, navigation +- TanStack Query: server state, caching, mutations +- Loader = bridge: prefetches into Query cache before render → zero loading spinners for route data +- Components are pure UI: read from Query cache, trigger mutations + +## Setup +```ts +// src/lib/queryClient.ts +export const queryClient = new QueryClient({ + defaultOptions: { queries: { staleTime: 60_000 } }, +}) + +// src/lib/router.ts +export const router = createRouter({ + routeTree, + context: { queryClient }, + defaultPreload: 'intent', + defaultPreloadStaleTime: 0, +}) + +declare module '@tanstack/react-router' { + interface Register { router: typeof router } +} + +// src/main.tsx + + + +``` + +## Query Definitions +```ts +// src/queries/posts.ts +export const postKeys = { + all: ['posts'] as const, + detail: (id: string) => [...postKeys.all, 'detail', id] as const, + list: (f?: PostFilters) => [...postKeys.all, 'list', f] as const, +} + +export const postQueryOptions = (id: string) => + queryOptions({ queryKey: postKeys.detail(id), queryFn: () => fetchPost(id) }) + +export const postsQueryOptions = (filters?: PostFilters) => + queryOptions({ queryKey: postKeys.list(filters), queryFn: () => fetchPosts(filters) }) +``` + +## Loader + Component (zero loading state) +```tsx +export const Route = createFileRoute('/posts/$postId')({ + loader: ({ context: { queryClient }, params }) => + queryClient.ensureQueryData(postQueryOptions(params.postId)), + component: PostDetail, +}) + +function PostDetail() { + const { postId } = Route.useParams() + const { data: post } = useQuery(postQueryOptions(postId)) // always in cache from loader + return

{post!.title}

+} +``` + +## Search Params → Query Key +```tsx +const searchSchema = z.object({ page: z.number().default(1), q: z.string().optional() }) + +export const Route = createFileRoute('/posts/')({ + validateSearch: searchSchema, + loader: ({ context: { queryClient }, location: { search } }) => + queryClient.ensureQueryData(postsQueryOptions(search)), + component: PostsList, +}) + +function PostsList() { + const search = Route.useSearch() + const { data } = useQuery(postsQueryOptions(search)) + // ... +} +``` + +## Mutations +```tsx +const mutation = useMutation({ + mutationFn: createPost, + onSuccess: (newPost) => { + queryClient.setQueryData(postKeys.detail(newPost.id), newPost) // warm cache + queryClient.invalidateQueries({ queryKey: postKeys.list() }) + navigate({ to: '/posts/$postId', params: { postId: newPost.id } }) // instant — no spinner + }, +}) +``` + +## Hover Prefetching +```tsx + queryClient.prefetchQuery(postQueryOptions(post.id))} +> + {post.title} + +``` + +## Key Rules +- Always define `queryOptions` outside components — never inline inside `useQuery()` +- Never use `useEffect` for data fetching — use loaders or `useQuery` +- Search params are the single source of truth for filter/pagination state +- After mutations: `setQueryData` + `invalidateQueries` for instant UI feedback +- `declare module '@tanstack/react-router'` router registration is required for full type safety diff --git a/.cursor/rules/rust-general.mdc b/.cursor/rules/rust-general.mdc new file mode 100644 index 0000000..618230d --- /dev/null +++ b/.cursor/rules/rust-general.mdc @@ -0,0 +1,52 @@ +--- +description: General Rust rules for safe, idiomatic application and library development +globs: ["**/*.rs", "Cargo.toml", "Cargo.lock"] +alwaysApply: false +--- + +# Rust General Rules + +## Project Structure + +- Keep crates focused and name modules by domain responsibility. +- Put reusable library code in `src/lib.rs` and binary entry points in `src/main.rs` or `src/bin/`. +- Keep public APIs small and documented. +- Use feature flags deliberately and document non-default features. +- Commit `Cargo.lock` for applications; follow the project convention for libraries. + +## Ownership and Types + +- Prefer borrowing over cloning when ownership is not needed. +- Use owned values at API boundaries when the callee must store data. +- Model domain states with enums and structs instead of strings or booleans. +- Use `Option` for absence and `Result` for fallible operations. +- Avoid `unwrap()` and `expect()` outside tests, examples, and process-startup invariants. + +## Error Handling + +- Use `thiserror` or project-standard custom errors for libraries. +- Use `anyhow` or project-standard context-rich errors for applications. +- Add context when crossing IO, network, database, or parsing boundaries. +- Do not discard errors with `_` unless explicitly documented. + +## Concurrency and Async + +- Use `Send` and `Sync` boundaries intentionally. +- Prefer message passing or owned task inputs for async work. +- Do not hold blocking locks across `.await`. +- Use `tokio::task::spawn_blocking` or equivalent for blocking CPU or IO in async applications. +- Propagate cancellation through futures rather than hiding it in detached tasks. + +## Testing and Quality + +- Run `cargo fmt` and `cargo clippy` before delivery. +- Add unit tests for pure logic and integration tests for public behavior. +- Use property tests for parsers, serializers, and state machines when useful. +- Use benchmarks only after identifying a real performance question. + +## Common Mistakes + +- Do not fight the borrow checker by adding unnecessary `Arc>`. +- Do not expose internal module structure through public APIs by accident. +- Do not allocate in hot loops without measuring. +- Do not use unsafe code unless the invariant is documented and tested. diff --git a/.cursor/rules/rust.mdc b/.cursor/rules/rust.mdc new file mode 100644 index 0000000..cb9144c --- /dev/null +++ b/.cursor/rules/rust.mdc @@ -0,0 +1,85 @@ +--- +description: "Rust best practices for Solana smart contract development using Anchor framework and Solana SDK" +globs: programs/**/*.rs, src/**/*.rs, tests/**/*.ts +alwaysApply: false +--- +# Rust + Solana (Anchor) Best Practices + +## Program Structure +- Structure Solana programs using `Anchor` framework standards +- Place program entrypoint logic in `lib.rs`, not `main.rs` +- Organize handlers into modules (e.g., `initialize`, `update`, `close`) +- Separate state definitions, errors, instructions, and utils +- Group reusable logic under a `utils` module (e.g., account validation) +- Use `declare_id!()` to define program ID + +## Anchor Framework +- Use `#[derive(Accounts)]` for all instruction contexts +- Validate accounts strictly using constraint macros (e.g., `#[account(mut)]`, `seeds`, `bump]`) +- Define all state structs with `#[account]` and `#[derive(AnchorSerialize, AnchorDeserialize)]` +- Prefer `Init`, `Close`, `Realloc`, `Mut`, and constraint macros to avoid manual deserialization +- Use `ctx.accounts` to access validated context accounts +- Handle CPI (Cross-Program Invocation) calls via Anchor’s CPI helpers + +## Serialization +- Use **Borsh** or Anchor's custom serializer (not Serde) for on-chain data +- Always include `#[account(zero_copy)]` or `#[repr(C)]` for packed structures +- Avoid floating point types — use `u64`, `u128`, or fixed-point math +- Zero out or close unused accounts to reduce rent costs + +## Testing +- Write tests in TypeScript using Anchor’s Mocha + Chai setup (`tests/*.ts`) +- Use `anchor.workspace.MyProgram` to load deployed contracts +- Use `provider.simulate()` to inspect failed txs +- Spin up a local validator (`anchor test`) and reset between tests +- Airdrop SOL to wallets with `provider.connection.requestAirdrop(...)` +- Validate program logs using `tx.confirmation.logMessages` + +## Solana SDK (Manual) +- Use `solana_program` crate when not using Anchor (bare-metal programs) +- Carefully deserialize accounts using `AccountInfo`, `try_from_slice_unchecked` +- Use `solana_program::msg!` for lightweight debugging logs +- Verify accounts via `is_signer`, `is_writable`, `key == expected` +- Never panic! Use `ProgramError::Custom(u32)` or `ErrorCode` enums + +## Security Patterns +- Always validate `msg.sender`/signer with `account_info.is_signer` +- Prevent replay attacks via `seeds`, `bump`, and unique PDAs +- Use strict size checks before reallocating or deserializing +- Avoid unsafe unchecked casting; prefer Anchor deserialization +- For CPIs, validate `target_program` against expected program ID +- When using randomness, never rely on timestamps — use oracles or off-chain VRFs + +## Performance +- Prefer zero-copy deserialization when accounts are large +- Minimize compute usage; avoid loops and recursion +- Avoid memory reallocations mid-instruction +- Use `#[account(zero_copy)]` and `#[repr(packed)]` for tight layout +- Profile compute units with `solana logs` and `anchor run` + +## Dev Workflow +- Use `anchor init` to scaffold projects +- Add Anchor IDL support for front-end usage (JSON ABI) +- Use `anchor build`, `anchor deploy`, `anchor test` consistently +- Use separate `Anchor.toml` environments for devnet/mainnet/localnet +- Format all Rust code with `cargo fmt`, lint with `cargo clippy` +- Keep `Cargo.lock` checked into `programs/` but not root + +## Documentation +- Use `///` Rust doc comments for all instructions and accounts +- Include doc examples for each instruction +- Document PDA derivation logic and bump seed expectations +- Maintain up-to-date `README.md` with test commands and deployment steps + +## Wallet & Network Handling +- Use `anchorProvider.wallet.publicKey` for signer verification in tests +- Do not hardcode keypairs — use env-based loading (`process.env.ANCHOR_WALLET`) +- Deploy with clear `cluster` targets (`localnet`, `devnet`, `mainnet`) +- Use `anchor keys sync` to propagate program ID changes +- Commit `target/idl/` and `target/types/` to share with front end + +## CI/CD & Deploy +- Use GitHub Actions with `solana-cli`, `anchor-cli`, and `node` installed +- Run `anchor test` in CI for every PR +- Use `solana program deploy` with explicit `--program-id` on production deploys +- Upload IDLs to a central registry (e.g., GitHub, IPFS, or `anchor.cloud`) diff --git a/.cursor/rules/shadcn-best-practices.mdc b/.cursor/rules/shadcn-best-practices.mdc new file mode 100644 index 0000000..cb94a14 --- /dev/null +++ b/.cursor/rules/shadcn-best-practices.mdc @@ -0,0 +1,310 @@ +--- +description: Definitive best practices for shadcn/ui — organization, TypeScript, performance, and accessible components. +globs: **/* +alwaysApply: false +--- + +# shadcn Best Practices + +Definitive guidelines for `shadcn/ui` development and integration. Application source lives under `src/`; components under `src/components/`. + +## 1. Code Organization and Structure + +Organize components to reflect UI hierarchy and promote discoverability. + +**Rule:** Place domain-specific components under `src/components/` and reusable UI primitives under `src/components/ui`. One primary component per file; use kebab-case filenames for UI primitives (shadcn CLI default) and PascalCase for exported component names. + +❌ BAD: +``` +// src/components/Button.tsx +// src/components/profile-card.tsx +// src/components/user-settings/index.tsx (multiple components in one file) +``` + +✅ GOOD: +``` +// src/components/ui/button.tsx +// src/components/forms/date-picker.tsx +// src/components/layout/sidebar.tsx + +// src/components/forms/index.ts +export * from "./date-picker"; +export * from "./input"; +``` + +## 2. Component Architecture + +Favor functional components, composition, and explicit prop definitions. + +**Rule:** Use functional components with `React.forwardRef` and `asChild` for seamless integration with Radix primitives. + +❌ BAD: +```tsx +// No ref forwarding, no asChild +const Button = ({ children, onClick }) => ( + +); +``` + +✅ GOOD: +```tsx +// src/components/ui/button.tsx +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-medium", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: "bg-destructive text-destructive-foreground", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + } +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; +``` + +## 3. TypeScript and API Design + +Enforce strict TypeScript with clear interfaces and robust validation. + +**Rule:** Use interfaces for component props. Validate form data with Zod schemas. Avoid `any` and prefer explicit types. + +❌ BAD: +```typescript +// Vague props, no validation +type UserFormProps = { + data: any; + onSubmit: (values: any) => void; +}; +``` + +✅ GOOD: +```typescript +// src/components/forms/user-form.tsx +import { z } from "zod"; + +export interface UserFormProps { + initialData?: UserFormData; + onSubmit: (values: UserFormData) => void; +} + +export const userFormSchema = z.object({ + id: z.string().optional(), + name: z.string().min(2, "Name must be at least 2 characters."), + email: z.string().email("Invalid email address."), +}); + +export type UserFormData = z.infer; +``` + +## 4. Theming and Styling + +Leverage Tailwind CSS and `class-variance-authority` (CVA) for consistent, maintainable styling. + +**Rule:** Define component variants using CVA. Use the `cn` utility for conditional class merging. Centralize Tailwind configuration and design tokens. + +❌ BAD: +```tsx +// Inconsistent inline styles or direct class manipulation + + {showChart && ( + Loading chart...}> + + + )} + + ); +} +``` + +## 7. Accessibility + +Build inclusive UIs by leveraging Radix primitives and ARIA attributes. + +**Rule:** Prefer `shadcn/ui` components (Radix-based) for built-in accessibility. Ensure custom components pass ARIA attributes and manage focus correctly. + +❌ BAD: +```tsx +// Custom button without ARIA attributes or proper semantics +
Click me
+``` + +✅ GOOD: +```tsx +import { Button } from "@/components/ui/button"; + + +``` + +## 8. Common Pitfalls and Gotchas + +**Rule:** Never directly modify `shadcn/ui` component files for one-off styling — extend with `cn` or wrap in higher-level components. Avoid `dangerouslySetInnerHTML` unless content is sanitized. + +❌ BAD: +```tsx +// Direct modification of a shadcn component (overwritten by CLI updates) +// src/components/ui/button.tsx (modified for a single use case) +``` + +```tsx +// Security vulnerability +
+``` + +✅ GOOD: +```tsx +import { Button } from "@/components/ui/button"; + + +``` + +```tsx +import DOMPurify from "dompurify"; + +const sanitizedContent = DOMPurify.sanitize(userProvidedContent); +return
; +// Prefer rendering text directly when possible: +// return

{userProvidedContent}

; +``` diff --git a/.cursor/rules/sqlite.mdc b/.cursor/rules/sqlite.mdc new file mode 100644 index 0000000..fc5ae71 --- /dev/null +++ b/.cursor/rules/sqlite.mdc @@ -0,0 +1,186 @@ +--- +description: Definitive guidelines for writing robust, performant, and secure SQLite code. Focuses on schema design, query optimization, and transaction management. +globs: **/* +--- +# sqlite Best Practices + +SQLite is the go-to embedded SQL engine for local, reliable storage. Adhere to these rules to ensure your SQLite code is maintainable, performant, and secure. + +## 1. Data Modeling & Schema Design + +Design your schema for integrity and performance from day one. + +* **Primary Keys**: Always use `INTEGER PRIMARY KEY AUTOINCREMENT` for ID columns. This optimizes `rowid` lookups and simplifies ID generation. + * ❌ BAD: + ```sql + CREATE TABLE users ( + id TEXT PRIMARY KEY, -- Manual UUIDs or similar + name TEXT NOT NULL + ); + ``` + * ✅ GOOD: + ```sql + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL + ); + ``` + +* **Data Types & Constraints**: Declare appropriate data types and enforce integrity with `NOT NULL`, `UNIQUE`, and `FOREIGN KEY` constraints. + * ❌ BAD: + ```sql + CREATE TABLE products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, -- Allows NULL, no uniqueness + price REAL + ); + ``` + * ✅ GOOD: + ```sql + CREATE TABLE products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + price REAL NOT NULL, + stock INTEGER DEFAULT 0, + category_id INTEGER, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL + ); + ``` + +* **Naming Conventions**: Use `lower_case_snake_case` for all table, column, and index names. Avoid SQLite keywords as identifiers. + * ❌ BAD: `CREATE TABLE My_Users ( UserId INTEGER PRIMARY KEY );` + * ✅ GOOD: `CREATE TABLE my_users ( user_id INTEGER PRIMARY KEY );` + +## 2. Performance Considerations + +Optimize for speed by minimizing I/O and leveraging the SQLite engine. + +* **Enable WAL Mode**: Always enable Write-Ahead Logging for better concurrency and write performance. + * ❌ BAD: Default journal mode (`DELETE`). + * ✅ GOOD (at database open or once): + ```sql + PRAGMA journal_mode = WAL; + ``` + +* **Relax Synchronous Mode**: When using WAL, set `synchronous` to `NORMAL` for faster commits, accepting minimal risk of data loss on power failure (not app crash). + * ❌ BAD: Default `synchronous = FULL`. + * ✅ GOOD (at database open or once): + ```sql + PRAGMA synchronous = NORMAL; + ``` + +* **Indexes**: Create indexes on columns frequently used in `WHERE`, `ORDER BY`, `GROUP BY`, or `JOIN` clauses. Avoid over-indexing. + * ❌ BAD: + ```sql + SELECT * FROM users WHERE email = 'test@example.com'; -- No index on email + ``` + * ✅ GOOD: + ```sql + CREATE INDEX idx_users_email ON users(email); + SELECT id, name FROM users WHERE email = 'test@example.com'; + ``` + * **Multi-column Indexes**: For queries filtering/sorting on multiple columns, create a multi-column index matching the query order. + ```sql + CREATE INDEX idx_products_category_price ON products(category_id, price); + SELECT * FROM products WHERE category_id = 1 ORDER BY price DESC; + ``` + +* **Query Optimization**: Select only the columns you need. Push filtering, sorting, and aggregation into SQL. + * ❌ BAD: + ```sql + SELECT * FROM products; -- Fetch all columns + -- Then filter/sort in application code + ``` + * ✅ GOOD: + ```sql + SELECT id, name, price FROM products WHERE stock > 0 ORDER BY price ASC LIMIT 10; + ``` + +## 3. Transactions & Concurrency + +Ensure data consistency and improve write performance with explicit transactions. + +* **Wrap Writes in Transactions**: Group multiple `INSERT`, `UPDATE`, `DELETE` operations within a single transaction. This significantly reduces disk I/O. + * ❌ BAD: + ```sql + INSERT INTO logs (action) VALUES ('User created'); + INSERT INTO users (name) VALUES ('New User'); + INSERT INTO logs (action) VALUES ('User name updated'); + UPDATE users SET name = 'Updated User' WHERE id = 1; + ``` + * ✅ GOOD: + ```sql + BEGIN; + INSERT INTO logs (action) VALUES ('User created'); + INSERT INTO users (name) VALUES ('New User'); + INSERT INTO logs (action) VALUES ('User name updated'); + UPDATE users SET name = 'Updated User' WHERE id = 1; + COMMIT; + ``` + +* **Error Handling**: Use `ROLLBACK` to revert all changes if any operation within a transaction fails. + * ✅ GOOD: + ```sql + BEGIN; + -- Perform operations + INSERT INTO users (name) VALUES ('Valid User'); + INSERT INTO users (name) VALUES (NULL); -- This will fail due to NOT NULL + -- If an error occurs, catch it and: + ROLLBACK; + -- Else: + COMMIT; + ``` + +## 4. Security Best Practices + +Prevent common vulnerabilities like SQL injection. + +* **Prepared Statements**: Always use prepared statements with bound parameters. NEVER concatenate user input directly into SQL queries. + * ❌ BAD: + ```sql + String name = userInput.getName(); + String sql = "INSERT INTO users (name) VALUES ('" + name + "');"; // SQL Injection risk! + ``` + * ✅ GOOD (using a typical API pattern): + ```sql + PreparedStatement stmt = connection.prepareStatement("INSERT INTO users (name) VALUES (?);"); + stmt.setString(1, userInput.getName()); + stmt.executeUpdate(); + ``` + +* **Enable Foreign Key Enforcement**: Always enable foreign key constraints at runtime. SQLite defaults to `OFF` for backward compatibility. + * ❌ BAD: Forgetting to enable foreign keys, leading to orphaned records. + * ✅ GOOD (at database open or once per connection): + ```sql + PRAGMA foreign_keys = ON; + ``` + +* **File Permissions**: Store database files in write-protected directories and set restrictive file permissions to limit unauthorized access. This is OS-specific but critical. + +## 5. Common Pitfalls & Gotchas + +Avoid these common mistakes that lead to bugs and performance issues. + +* **Forgetting `PRAGMA foreign_keys = ON;`**: This is the most common pitfall. Always enable it. +* **Selecting `*`**: Only retrieve the columns you actually need. +* **Application-level Filtering/Sorting**: Delegate these operations to SQL for better performance, especially on large datasets. +* **Not Using Transactions**: Leads to slow writes and potential data inconsistencies. +* **Using SQLite for High-Concurrency Writes**: SQLite is a single-writer database. If multiple processes need to write concurrently, consider a client-server RDBMS. + +## 6. Testing Approaches + +Ensure your data access logic is robust and correct. + +* **In-Memory Databases**: Use `:memory:` databases for fast, isolated unit and integration tests of your data access layer. + * ✅ GOOD (example in Python, similar patterns exist in other languages): + ```python + import sqlite3 + conn = sqlite3.connect(':memory:') + cursor = conn.cursor() + cursor.execute("CREATE TABLE test_data (id INTEGER PRIMARY KEY, value TEXT)") + # ... run tests ... + conn.close() # Database vanishes + ``` + +* **Seed Data**: Create consistent, reproducible test data for your tests. +* **Mocking**: For higher-level tests, mock your database interactions to focus on business logic. \ No newline at end of file diff --git a/.cursor/rules/tailwind-shadcn-development.mdc b/.cursor/rules/tailwind-shadcn-development.mdc new file mode 100644 index 0000000..23aa567 --- /dev/null +++ b/.cursor/rules/tailwind-shadcn-development.mdc @@ -0,0 +1,64 @@ +--- +description: Cursor rules for Tailwind development with shadcn/ui integration. +globs: **/* +alwaysApply: false +--- + +# Tailwind + shadcn/ui Development + +You are an expert AI programming assistant in VSCode that primarily focuses on producing clear, readable TypeScript Next.js code. + +You are thoughtful, give nuanced answers, and are brilliant at reasoning. You carefully provide accurate, factual, thoughtful answers, and are a genius at reasoning. + +Follow the user's requirements carefully & to the letter. + +First think step-by-step — describe your plan for what to build in pseudocode, written out in great detail. + +Confirm, then write code! + +Always write correct, up-to-date, bug-free, fully functional and working, secure, performant and efficient code. + +Focus on readability over being performant. + +Fully implement all requested functionality. + +Leave NO todo's, placeholders or missing pieces. + +Ensure code is complete! Verify thoroughly finalized. + +Include all required imports, and ensure proper naming of key components. + +Be concise. Minimize any other prose. + +If you think there might not be a correct answer, you say so. If you do not know the answer, say so instead of guessing. + +## Tech Stack + +- Next.js (App Router), React, TypeScript +- Tailwind CSS for all styling +- shadcn/ui + Radix UI for accessible components +- Application source files live in the `src/` folder + +## Tailwind CSS + +- Use Tailwind utility classes for styling; avoid custom CSS unless necessary +- Prefer design tokens (`bg-background`, `text-foreground`, `border-border`) over hardcoded colors +- Use `cn()` from `@/lib/utils` to merge conditional classes +- Compose with responsive and state variants (`sm:`, `md:`, `hover:`, `focus-visible:`) +- Keep class lists readable; extract repeated patterns into components + +## shadcn/ui + +- Prefer existing shadcn components from `@/components/ui/` before building from scratch +- Add new components with the shadcn CLI; do not copy-paste from docs without project setup +- Extend shadcn components via `className` and composition, not by editing primitives unless required +- Use Radix behavior and shadcn styling patterns for forms, dialogs, dropdowns, and toasts +- Wire forms with `react-hook-form` + `zod` when using shadcn form components + +## Code Guidelines + +- Use early returns for readability +- Prefix event handlers with `handle` (e.g. `handleClick`, `handleSubmit`) +- Prefix booleans with verbs (`isLoading`, `hasError`, `canSubmit`) +- Default to Server Components; use `'use client'` only when needed +- Use semantic HTML and accessible labels, focus states, and keyboard support diff --git a/.cursor/rules/tanstack-query-best-practices.mdc b/.cursor/rules/tanstack-query-best-practices.mdc new file mode 100644 index 0000000..5d5367e --- /dev/null +++ b/.cursor/rules/tanstack-query-best-practices.mdc @@ -0,0 +1,396 @@ +--- +description: Definitive guidelines for using TanStack Query (formerly React Query) to manage server state efficiently, ensure type safety, and optimize performance in React applications. +globs: **/*.{js,jsx,ts,tsx} +alwaysApply: false +--- + +# TanStack Query (react-query) Best Practices + +This document outlines the definitive best practices for using TanStack Query in our React applications. Adhering to these guidelines ensures consistent, performant, and maintainable data fetching and state management. + +## 1. Query Keys: The Foundation of Caching + +**ALWAYS** use stable, descriptive array keys. These are fundamental for caching, refetching, and invalidation. For dynamic data, embed parameters directly into the array. + +### ✅ GOOD: Stable Array Keys & Key Factories + +```typescript +// 1. Simple, static key +const USERS_KEY = ['users']; + +// 2. Dynamic key with parameters +const userKeys = { + all: ['users'] as const, + lists: () => [...userKeys.all, 'list'] as const, + list: (filters: { status?: string; page?: number }) => + [...userKeys.lists(), { filters }] as const, + details: () => [...userKeys.all, 'detail'] as const, + detail: (id: string) => [...userKeys.details(), id] as const, +}; + +// Usage: +// useQuery(userKeys.all, fetchAllUsers); +// useQuery(userKeys.list({ status: 'active', page: 1 }), fetchUsers); +// useQuery(userKeys.detail(userId), fetchUserById); +``` + +### ❌ BAD: Unstable or Generic Keys + +```typescript +// String keys are less flexible for dynamic data and filtering +useQuery('users', fetchUsers); + +// Anonymous object keys are unstable and break caching +useQuery(['users', { id: userId }], fetchUserById); // Object literal creates new reference each render +``` + +## 2. Custom Hooks: Encapsulate Logic + +**ALWAYS** wrap `useQuery` and `useMutation` calls in custom hooks. This centralizes data fetching logic, improves reusability, enhances type safety, and keeps components clean. + +### ✅ GOOD: Dedicated Custom Hooks + +```typescript +// hooks/useUsers.ts +import { useQuery } from '@tanstack/react-query'; +import { fetchUsers, User } from '../api'; // Assume api.ts defines fetchUsers + +const userKeys = { + all: ['users'] as const, + list: (filters: { status?: string }) => [...userKeys.all, { filters }] as const, +}; + +export function useUsers(filters?: { status?: string }) { + return useQuery({ + queryKey: userKeys.list(filters || {}), + queryFn: () => fetchUsers(filters), + }); +} + +// components/UserList.tsx +import { useUsers } from '../hooks/useUsers'; + +function UserList({ statusFilter }: { statusFilter?: string }) { + const { data: users, isLoading, error } = useUsers({ status: statusFilter }); + + if (isLoading) return
Loading users...
; + if (error) return
Error: {error.message}
; + + return ( +
    + {users?.map((user) => ( +
  • {user.name}
  • + ))} +
+ ); +} +``` + +### ❌ BAD: Direct `useQuery` in Components + +```typescript +// components/UserList.tsx +import { useQuery } from '@tanstack/react-query'; +import { fetchUsers } from '../api'; + +function UserList({ statusFilter }: { statusFilter?: string }) { + // Logic is duplicated if another component needs users + // Query key is less organized + const { data: users, isLoading, error } = useQuery({ + queryKey: ['users', { status: statusFilter }], + queryFn: () => fetchUsers({ status: statusFilter }), + }); + + // ... rest of component +} +``` + +## 3. Query Functions: Separate and Stable + +**NEVER** pass anonymous functions directly to `queryFn`. **ALWAYS** declare `queryFn` separately to ensure stability, prevent unnecessary re-renders, and improve testability. + +### ✅ GOOD: Separated Query Functions + +```typescript +// api.ts +export async function fetchUserById(id: string): Promise { + const response = await fetch(`/api/users/${id}`); + if (!response.ok) throw new Error('Failed to fetch user'); + return response.json(); +} + +// hooks/useUser.ts +import { useQuery } from '@tanstack/react-query'; +import { fetchUserById } from '../api'; + +const userKeys = { + detail: (id: string) => ['users', id] as const, +}; + +export function useUser(userId: string) { + return useQuery({ + queryKey: userKeys.detail(userId), + queryFn: () => fetchUserById(userId), // Stable reference to fetchUserById + enabled: !!userId, // Only run if userId exists + }); +} +``` + +### ❌ BAD: Anonymous Query Functions + +```typescript +// hooks/useUser.ts +import { useQuery } from '@tanstack/react-query'; + +export function useUser(userId: string) { + return useQuery({ + queryKey: ['users', userId], + // This anonymous function is recreated on every render, potentially causing issues + queryFn: async () => { + const response = await fetch(`/api/users/${userId}`); + if (!response.ok) throw new Error('Failed to fetch user'); + return response.json(); + }, + enabled: !!userId, + }); +} +``` + +## 4. Conditional Fetching: Use `enabled` + +**ALWAYS** use the `enabled` option for conditional fetching. This is the explicit and recommended way to control when a query runs. + +### ✅ GOOD: Using `enabled` + +```typescript +// hooks/useUserProfile.ts +import { useQuery } from '@tanstack/react-query'; +import { fetchUserProfile } from '../api'; + +export function useUserProfile(userId?: string) { + return useQuery({ + queryKey: ['userProfile', userId], + queryFn: () => fetchUserProfile(userId!), + enabled: !!userId, // Query only runs if userId is truthy + }); +} +``` + +### ❌ BAD: Conditional Hook Calls + +```typescript +// components/UserProfile.tsx +import { useUserProfile } from '../hooks/useUserProfile'; + +function UserProfile({ userId }: { userId?: string }) { + // React Hook Rules: Hooks must be called unconditionally + // This breaks the rules and will cause bugs + if (!userId) { + return null; + } + const { data: user, isLoading } = useUserProfile(userId); + // ... +} +``` + +## 5. Data Transformation: Use `select` + +**ALWAYS** use the `select` option within `useQuery` for transforming or filtering data. This ensures the transformation happens once at the query level, optimizing performance and preventing redundant calculations in components. + +### ✅ GOOD: `select` for Transformations + +```typescript +// hooks/useActiveUsers.ts +import { useQuery } from '@tanstack/react-query'; +import { fetchUsers, User } from '../api'; + +export function useActiveUsers() { + return useQuery({ // Specify transformed data type + queryKey: ['users', 'all'], + queryFn: fetchUsers, + select: (data) => data.filter(user => user.status === 'active').map(user => user.name), + }); +} + +// components/ActiveUserNames.tsx +import { useActiveUsers } from '../hooks/useActiveUsers'; + +function ActiveUserNames() { + const { data: activeUserNames, isLoading } = useActiveUsers(); + + if (isLoading) return
Loading active users...
; + return ( +
    + {activeUserNames?.map((name) => ( +
  • {name}
  • + ))} +
+ ); +} +``` + +### ❌ BAD: Transforming Data in Every Component + +```typescript +// components/ActiveUserNames.tsx +import { useQuery } from '@tanstack/react-query'; +import { fetchUsers } from '../api'; + +function ActiveUserNames() { + const { data: users, isLoading } = useQuery({ + queryKey: ['users', 'all'], + queryFn: fetchUsers, + }); + + // Transformation logic repeated or inefficiently placed + const activeUserNames = users?.filter(user => user.status === 'active').map(user => user.name); + + if (isLoading) return
Loading active users...
; + return ( +
    + {activeUserNames?.map((name) => ( +
  • {name}
  • + ))} +
+ ); +} +``` + +## 6. Mutations and Cache Invalidation + +**ALWAYS** use `useMutation` for CUD (Create, Update, Delete) operations. After a successful mutation, **ALWAYS** invalidate relevant queries to ensure the UI reflects the latest server state. For immediate feedback, consider optimistic updates with `setQueryData`. + +### ✅ GOOD: Invalidation after Mutation + +```typescript +// hooks/useCreateTodo.ts +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { createTodo, Todo } from '../api'; + +export function useCreateTodo() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createTodo, + onSuccess: () => { + // Invalidate all 'todos' queries to refetch fresh data + queryClient.invalidateQueries({ queryKey: ['todos'] }); + }, + }); +} + +// components/TodoForm.tsx +import { useCreateTodo } from '../hooks/useCreateTodo'; + +function TodoForm() { + const { mutate, isLoading } = useCreateTodo(); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const formData = new FormData(event.currentTarget as HTMLFormElement); + const title = formData.get('title') as string; + mutate({ title }); + }; + + return ( +
+ + +
+ ); +} +``` + +### ✅ GOOD: Optimistic Updates + +```typescript +// hooks/useUpdateTodo.ts +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { updateTodo, Todo } from '../api'; + +export function useUpdateTodo() { + const queryClient = useQueryClient(); + return useMutation & { id: string }>({ + mutationFn: updateTodo, + // Optimistically update the cache + onMutate: async (newTodo) => { + await queryClient.cancelQueries({ queryKey: ['todos'] }); + const previousTodos = queryClient.getQueryData(['todos']); + queryClient.setQueryData(['todos'], (old) => + old ? old.map((todo) => (todo.id === newTodo.id ? { ...todo, ...newTodo } : todo)) : [] + ); + return { previousTodos }; // Context for onError + }, + onError: (err, newTodo, context) => { + // Rollback on error + queryClient.setQueryData(['todos'], context?.previousTodos); + }, + onSettled: () => { + // Always refetch after error or success to ensure data is in sync + queryClient.invalidateQueries({ queryKey: ['todos'] }); + }, + }); +} +``` + +## 7. Performance: Prefetching & Defaults + +**LEVERAGE** TanStack Query's defaults (e.g., `staleTime: 0`, automatic retries, refetch on window focus) and **STRATEGICALLY** use prefetching for critical user flows. + +### ✅ GOOD: Prefetching for Router Integration + +```typescript +// utils/routeLoaders.ts (Example with a router loader) +import { QueryClient } from '@tanstack/react-query'; +import { fetchProjectById } from '../api'; + +export const projectLoader = (queryClient: QueryClient) => async ({ params }: { params: { projectId: string } }) => { + const queryKey = ['projects', params.projectId]; + // Prefetch the project data during navigation + await queryClient.prefetchQuery({ + queryKey, + queryFn: () => fetchProjectById(params.projectId), + }); + return null; // Or return initial data if needed +}; + +// components/ProjectLink.tsx +import { Link } from 'react-router-dom'; // Assuming react-router-dom +import { useQueryClient } from '@tanstack/react-query'; +import { fetchProjectById } from '../api'; + +function ProjectLink({ projectId, projectName }: { projectId: string; projectName: string }) { + const queryClient = useQueryClient(); + const handleMouseEnter = () => { + // Prefetch on hover for instant page loads + queryClient.prefetchQuery({ + queryKey: ['projects', projectId], + queryFn: () => fetchProjectById(projectId), + staleTime: 5 * 60 * 1000, // Keep data fresh for 5 minutes + }); + }; + + return ( + + {projectName} + + ); +} +``` + +## 8. ESLint Plugin: Enforce Standards + +**ALWAYS** install and configure the `@tanstack/query-eslint-plugin`. It enforces many of these best practices automatically, catching common mistakes early. + +```json +// .eslintrc.json +{ + "plugins": ["@tanstack/query"], + "rules": { + "@tanstack/query/exhaustive-deps": "error", + "@tanstack/query/prefer-query-object": "error", + "@tanstack/query/stable-query-client": "error" + } +} +``` diff --git a/.cursor/rules/tanstack-query.mdc b/.cursor/rules/tanstack-query.mdc new file mode 100644 index 0000000..ab87bbe --- /dev/null +++ b/.cursor/rules/tanstack-query.mdc @@ -0,0 +1,107 @@ +--- +description: "TanStack Query v5 (React Query) patterns including queryOptions helper, query key factories, mutations, optimistic updates, infinite queries, Suspense mode, and prefetching" +globs: ["src/**/*.tsx", "src/**/*.ts", "src/queries/**/*"] +alwaysApply: false +--- +You are an expert in TanStack Query v5 (React Query), TypeScript, and async state management. + +## Core Principles +- TanStack Query manages server state — NOT a general client state manager +- Every query needs a stable, serializable query key that uniquely describes the data +- Mutations handle writes; queries handle reads — never blur this boundary +- Use `queryOptions()` helper (v5) for reusable, co-located query definitions +- v5 breaking change: `useQuery` only accepts options object form — no positional args + +## QueryClient Setup +```tsx +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60, + retry: (count, error: any) => error?.status !== 404 && count < 2, + }, + }, +}) +``` + +## Query Key Factory Pattern +```ts +export const postKeys = { + all: ['posts'] as const, + lists: () => [...postKeys.all, 'list'] as const, + list: (filters?: PostFilters) => [...postKeys.lists(), filters] as const, + details: () => [...postKeys.all, 'detail'] as const, + detail: (id: string) => [...postKeys.details(), id] as const, +} +``` + +## queryOptions Helper (v5) +```ts +export const postQueryOptions = (id: string) => + queryOptions({ + queryKey: postKeys.detail(id), + queryFn: () => fetchPost(id), + staleTime: 1000 * 60 * 5, + }) + +// In component +const { data } = useQuery(postQueryOptions(postId)) + +// In router loader +loader: ({ params, context: { queryClient } }) => + queryClient.ensureQueryData(postQueryOptions(params.postId)) +``` + +## Mutations +```tsx +const { mutate, isPending } = useMutation({ + mutationFn: (input: CreatePostInput) => createPost(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: postKeys.lists() }) + }, + onError: (error) => toast.error(error.message), +}) +``` + +## Optimistic Updates +```tsx +const mutation = useMutation({ + mutationFn: updatePost, + onMutate: async (updated) => { + await queryClient.cancelQueries({ queryKey: postKeys.detail(updated.id) }) + const previous = queryClient.getQueryData(postKeys.detail(updated.id)) + queryClient.setQueryData(postKeys.detail(updated.id), updated) + return { previous } + }, + onError: (_, updated, ctx) => { + queryClient.setQueryData(postKeys.detail(updated.id), ctx?.previous) + }, + onSettled: (_, __, updated) => { + queryClient.invalidateQueries({ queryKey: postKeys.detail(updated.id) }) + }, +}) +``` + +## Infinite Queries +```tsx +const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({ + queryKey: postKeys.lists(), + queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor, +}) +const allPosts = data?.pages.flatMap((p) => p.items) ?? [] +``` + +## Suspense Mode (v5) +```tsx +// useSuspenseQuery — no isLoading needed, Suspense handles it +const { data } = useSuspenseQuery(postQueryOptions(postId)) +// Wrap with }> + +``` + +## Key Rules +- Always define `queryOptions` outside components — never inline in `useQuery()` +- Never use `useEffect` to fetch data — use loaders or `useQuery` +- Use `placeholderData: keepPreviousData` for pagination to avoid layout shifts +- Instantiate `QueryClient` once at app root — never inside a component diff --git a/.cursor/rules/tanstack-router.mdc b/.cursor/rules/tanstack-router.mdc new file mode 100644 index 0000000..82d9bfe --- /dev/null +++ b/.cursor/rules/tanstack-router.mdc @@ -0,0 +1,98 @@ +--- +description: "Type-safe routing with TanStack Router v1 for React apps, including file-based routing, loaders, search params validation, auth guards, and TanStack Query integration" +globs: ["src/routes/**/*", "src/routeTree.gen.ts", "app.config.ts"] +alwaysApply: false +--- +You are an expert in TanStack Router v1, React, TypeScript, and type-safe client-side routing. + +## Core Principles +- TanStack Router is 100% type-safe — leverage TypeScript generics for params, search params, and loader data +- Prefer file-based routing with `@tanstack/router-vite-plugin` for scalability +- Always define routes with `createFileRoute` or `createRootRoute` +- Route data loading belongs in `loader` functions, not in component `useEffect` +- Search params are first-class — always define their schema with Zod for type safety + +## File-Based Route Conventions +``` +src/routes/ + __root.tsx ← Root layout + index.tsx ← / route + posts/ + index.tsx ← /posts + $postId.tsx ← /posts/:postId (dynamic) + _layout.tsx ← Layout route (no path segment) + _auth/ ← Pathless auth layout group + dashboard.tsx +``` + +## Route Definition +```tsx +export const Route = createFileRoute('/posts/$postId')({ + loader: async ({ params }) => fetchPost(params.postId), + component: PostComponent, + errorComponent: ({ error }) => , + pendingComponent: () => , +}) + +function PostComponent() { + const post = Route.useLoaderData() // type-safe + const { postId } = Route.useParams() // type-safe + return
{post.title}
+} +``` + +## Type-Safe Search Params +- Always define search params with Zod and `validateSearch` +- Access with `Route.useSearch()` — never read `window.location.search` directly +```tsx +const searchSchema = z.object({ + page: z.number().int().min(1).default(1), + q: z.string().optional(), +}) + +export const Route = createFileRoute('/search')({ + validateSearch: searchSchema, + component: SearchPage, +}) +``` + +## Navigation +- Use `` for internal navigation — never `` +- Always pass typed `params` and `search` — the compiler will catch mistakes +```tsx +View Post +``` + +## Loaders + TanStack Query Integration +```tsx +export const Route = createFileRoute('/posts')({ + loader: ({ context: { queryClient } }) => + queryClient.ensureQueryData(postsQueryOptions()), + component: PostsPage, +}) +``` + +## Router Context for Dependency Injection +```tsx +// __root.tsx +interface RouterContext { queryClient: QueryClient; auth: AuthState } +export const Route = createRootRouteWithContext()({ component: RootLayout }) + +// main.tsx +const router = createRouter({ routeTree, context: { queryClient, auth } }) +``` + +## Auth Guards +```tsx +export const Route = createFileRoute('/_auth/dashboard')({ + beforeLoad: ({ context }) => { + if (!context.auth.isAuthenticated) throw redirect({ to: '/login' }) + }, + component: Dashboard, +}) +``` + +## Performance +- Set `defaultPreload: 'intent'` on router for automatic prefetching on hover/focus +- Use `React.lazy` for route component code splitting +- Install `@tanstack/router-devtools` and render `` in development diff --git a/.cursor/rules/tanstack-start.mdc b/.cursor/rules/tanstack-start.mdc new file mode 100644 index 0000000..71b9f15 --- /dev/null +++ b/.cursor/rules/tanstack-start.mdc @@ -0,0 +1,123 @@ +--- +description: "TanStack Start full-stack React framework using server functions, API routes, SSR, streaming with defer(), and multi-platform deployment via Vinxi/Nitro" +globs: ["src/routes/**/*", "src/server/**/*", "app.config.ts"] +alwaysApply: false +--- +You are an expert in TanStack Start, TanStack Router, React, TypeScript, and full-stack type-safe web applications. + +## Core Principles +- TanStack Start = TanStack Router + Vinxi (Vite + Nitro) for full-stack React +- `createServerFn` is the primary way to run server-side logic with end-to-end type safety +- All TanStack Router conventions apply — file-based routing, loaders, search params, etc. +- Server functions replace REST endpoints for most use cases +- Streaming + Suspense are first-class — use `defer()` for non-critical data + +## app.config.ts +```ts +import { defineConfig } from '@tanstack/start/config' +import tsConfigPaths from 'vite-tsconfig-paths' + +export default defineConfig({ + vite: { plugins: [tsConfigPaths()] }, + server: { + preset: 'node-server', // or: 'vercel', 'netlify', 'bun', 'cloudflare-pages' + }, +}) +``` + +## Root Route HTML Shell +```tsx +// src/routes/__root.tsx +export const Route = createRootRoute({ + component: () => ( + + + + + + + + + ), +}) +``` + +## Server Functions +```ts +// src/server/functions/posts.ts +export const getPost = createServerFn() + .validator(z.object({ id: z.string() })) + .handler(async ({ data }) => { + const post = await db.post.findUnique({ where: { id: data.id } }) + if (!post) throw new Error('Post not found') + return post + }) + +export const createPost = createServerFn() + .validator(z.object({ title: z.string().min(1), body: z.string() })) + .handler(async ({ data }) => db.post.create({ data })) +``` + +## Using Server Functions in Routes +```tsx +export const Route = createFileRoute('/posts/$postId')({ + loader: ({ params }) => getPost({ data: { id: params.postId } }), + component: PostDetail, +}) +``` + +## Mutations with Server Functions +```tsx +const mutation = useMutation({ + mutationFn: (input: { title: string; body: string }) => createPost({ data: input }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['posts'] }), +}) +``` + +## API Routes (for webhooks / raw HTTP) +```ts +// src/routes/api/webhook.ts +export const Route = createAPIFileRoute('/api/webhook')({ + POST: async ({ request }) => { + const body = await request.json() + return Response.json({ received: true }) + }, +}) +``` + +## Streaming with defer() +```tsx +export const Route = createFileRoute('/posts/$postId')({ + loader: async ({ params }) => { + const post = await getPost({ data: { id: params.postId } }) // awaited = critical + const comments = getComments({ data: { postId: params.postId } }) // not awaited + return { post, comments: defer(comments) } + }, + component: PostDetail, +}) + +function PostDetail() { + const { post, comments } = Route.useLoaderData() + return ( +
+

{post.title}

+ }> + {(c) => } + +
+ ) +} +``` + +## Environment Variables +- Access server-only vars via `process.env` inside server functions only +- Use `import.meta.env.VITE_*` for client-exposed variables +- Never access `process.env` in client components + +## Deployment Targets +Configure `server.preset` in `app.config.ts`: +- `node-server` — default Node.js +- `vercel` — Vercel serverless/edge +- `netlify` — Netlify Functions +- `bun` — Bun runtime +- `cloudflare-pages` — Cloudflare Pages + Workers diff --git a/.cursor/rules/typescript-vite-tailwind-cursorrules-prompt-file.mdc b/.cursor/rules/typescript-vite-tailwind-cursorrules-prompt-file.mdc new file mode 100644 index 0000000..6f478a3 --- /dev/null +++ b/.cursor/rules/typescript-vite-tailwind-cursorrules-prompt-file.mdc @@ -0,0 +1,68 @@ +--- +description: "Cursor rules for TypeScript development with Vite and Tailwind integration." +globs: **/* +alwaysApply: false +--- +You are an expert in TypeScript, Node.js, Vite, Vue.js, Vue Router, Pinia, VueUse, DaisyUI, and Tailwind, with a deep understanding of best practices and performance optimization techniques in these technologies. + +Code Style and Structure + +- Write concise, maintainable, and technically accurate TypeScript code with relevant examples. +- Use functional and declarative programming patterns; avoid classes. +- Favor iteration and modularization to adhere to DRY principles and avoid code duplication. +- Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError). +- Organize files systematically: each file should contain only related content, such as exported components, subcomponents, helpers, static content, and types. + +Naming Conventions + +- Use lowercase with dashes for directories (e.g., components/auth-wizard). +- Favor named exports for functions. + +TypeScript Usage + +- Use TypeScript for all code; prefer interfaces over types for their extendability and ability to merge. +- Avoid enums; use maps instead for better type safety and flexibility. +- Use functional components with TypeScript interfaces. + +Syntax and Formatting + +- Use the "function" keyword for pure functions to benefit from hoisting and clarity. +- Always use the Vue Composition API script setup style. + +UI and Styling + +- Use DaisyUI, and Tailwind for components and styling. +- Implement responsive design with Tailwind CSS; use a mobile-first approach. + +Performance Optimization + +- Leverage VueUse functions where applicable to enhance reactivity and performance. +- Wrap asynchronous components in Suspense with a fallback UI. +- Use dynamic loading for non-critical components. +- Optimize images: use WebP format, include size data, implement lazy loading. +- Implement an optimized chunking strategy during the Vite build process, such as code splitting, to generate smaller bundle sizes. + +Key Conventions + +- Optimize Web Vitals (LCP, CLS, FID) using tools like Lighthouse or WebPageTest. +- Use the VueUse library for performance-enhancing functions. +- Implement lazy loading for non-critical components. +- Optimize images: use WebP format, include size data, implement lazy loading. +- Implement an optimized chunking strategy during the Vite build process, such as code splitting, to generate smaller bundle sizes. + +Code Review + +- Review code for performance, readability, and adherence to best practices. +- Ensure all components and functions are optimized for performance and maintainability. +- Check for unnecessary re-renders and optimize them using VueUse functions. +- Use the VueUse library for performance-enhancing functions. +- Implement lazy loading for non-critical components. +- Optimize images: use WebP format, include size data, implement lazy loading. +- Implement an optimized chunking strategy during the Vite build process, such as code splitting, to generate smaller bundle sizes. + +Best Practices + +- Use the VueUse library for performance-enhancing functions. +- Implement lazy loading for non-critical components. +- Optimize images: use WebP format, include size data, implement lazy loading. +- Implement an optimized chunking strategy during the Vite build process, such as code splitting, to generate smaller bundle sizes. diff --git a/.cursor/rules/ultimate-frontend-development-guide.mdc b/.cursor/rules/ultimate-frontend-development-guide.mdc new file mode 100644 index 0000000..a4ede61 --- /dev/null +++ b/.cursor/rules/ultimate-frontend-development-guide.mdc @@ -0,0 +1,137 @@ +--- +alwaysApply: true +--- + +# The Ultimate Frontend Development Guide: Principles, Patterns, and Practices + + ## Development Philosophy + + - **First Principles**: Embrace SOLID principles, KISS (Keep It Simple, Stupid), and DRY (Don't Repeat Yourself) + - **Functional Over Object-Oriented**: Favor functional and declarative programming patterns over imperative and OOP + - **Component-Driven Development**: Build applications as compositions of well-defined, reusable components + - **Type Safety**: Leverage TypeScript to its fullest potential for enhanced developer experience and code quality + - **Think Then Code**: Begin with step-by-step planning and detailed pseudocode before implementation + + ## Code Architecture & Structure + + ### Project Organization + - Use lowercase with dashes for directories (`components/auth-wizard/`) + - Structure files consistently: + 1. Exported component/functionality + 2. Subcomponents/helpers + 3. Static content + 4. Types/interfaces + + ### Naming Conventions + + - **PascalCase** for: + - Components (`UserProfile`) + - Type definitions/Interfaces (`UserData`) + + - **kebab-case** for: + - Directory names (`components/auth-wizard/`) + - File names (`user-profile.tsx`) + + - **camelCase** for: + - Variables, functions, methods + - Hooks, properties, props + + - **Descriptive Prefixes**: + - Prefix event handlers with 'handle': `handleClick`, `handleSubmit` + - Prefix boolean variables with verbs: `isLoading`, `hasError`, `canSubmit` + - Prefix custom hooks with 'use': `useAuth`, `useForm` + + ## TypeScript Implementation + + - Enable strict mode + - Prefer interfaces over types for object structures, especially when extending + - Use type guards for null/undefined values + - Apply generics for type flexibility + - Leverage TypeScript utility types (`Partial<>`, `Pick<>`, `Omit<>`) + - Avoid enums; use const objects/maps instead + - Use discriminated unions for complex state management + + ## React & Next.js Best Practices + + ### Component Patterns + + - Use functional components with explicit TypeScript interfaces + - Use the `function` keyword for component definitions, not arrow functions + - Extract reusable logic into custom hooks + - Place static content in variables outside render functions + - Implement proper cleanup in useEffect hooks + + ### Server Components First + + - Default to Server Components + - Use `'use client'` directive sparingly, only when necessary: + - Event listeners + - Browser APIs + - State that must be client-side + - Client-side-only libraries + - Use URL query parameters for server state management + - Implement proper data fetching using Next.js patterns + + ### Performance Optimizations + + - Use React.memo() strategically + - Implement useCallback for event handlers passed to child components + - Use useMemo for expensive computations + - Avoid inline function definitions in JSX + - Implement code splitting using dynamic imports + - Use proper key props in lists (avoid using index as key) + - Wrap client components in Suspense with appropriate fallbacks + + ## UI and Styling + + - Use Tailwind CSS for utility-first, maintainable styling + - Leverage component libraries like Shadcn UI and Radix UI for accessible, composable UI + - Design with mobile-first, responsive principles + - Implement dark mode using CSS variables or Tailwind's dark mode features + - Maintain consistent spacing values and design tokens + - Use Framer Motion library for the animations of components + + ## Error Handling - The Art of Graceful Failures + + ### The Early Return Pattern + + - Handle errors and edge cases at the beginning of functions + - Use early returns for error conditions + - Place the happy path last in the function + - Avoid unnecessary else statements; use if-return pattern instead + - Implement guard clauses to handle preconditions + + ### Structured Error Handling + + - Use custom error types for consistent error handling + - For Next.js Server Actions, model expected errors as return values + - Implement error boundaries using error.tsx files + - Provide user-friendly error messages + - Log errors appropriately for debugging + + ## Form Validation + + - Use Zod for schema validation + - Implement proper error messages + - Use react-hook-form for form state management + - Combine with useActionState for server actions + + ## State Management + + - Use useState for simple component-level state + - Implement useReducer for complex local state + - Use React Context for shared state within a component tree + - For global state, choose appropriate tools: + - Redux Toolkit for complex applications + - Zustand for simpler state management + - TanStack Query for server state + + ## Accessibility (a11y) + + - Use semantic HTML elements + - Apply appropriate ARIA attributes only when necessary + - Ensure keyboard navigation support + - Maintain accessible color contrast ratios + - Follow a logical heading hierarchy + - Provide clear and accessible error feedback + - Test with screen readers diff --git a/.cursor/rules/vite.mdc b/.cursor/rules/vite.mdc new file mode 100644 index 0000000..f19844d --- /dev/null +++ b/.cursor/rules/vite.mdc @@ -0,0 +1,300 @@ +--- +description: This guide provides definitive best practices for developing high-performance, maintainable applications with Vite, focusing on optimal configuration, code structure, and testing. +globs: **/*.{js,jsx} +--- +# vite Best Practices + +Vite is the modern standard for frontend tooling. Adhere to these principles to leverage its full potential, ensuring blazing-fast development and optimized production builds. + +## 1. Code Organization and Structure + +### Keep `vite.config.js` Minimal +Vite's philosophy is a lean core. Avoid over-configuring. Only add plugins or options when absolutely necessary. + +❌ **BAD** - Overly complex `vite.config.js` +```javascript +// vite.config.js +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import legacy from '@vitejs/plugin-legacy'; +import { visualizer } from 'rollup-plugin-visualizer'; +import { VitePWA } from 'vite-plugin-pwa'; + +export default defineConfig({ + plugins: [ + react(), + legacy({ targets: ['defaults', 'not IE 11'] }), + visualizer({ filename: './dist/stats.html' }), + VitePWA({ registerType: 'autoUpdate' }), + // ... many more plugins + ], + resolve: { + alias: { + '@': '/src', + '~': '/node_modules', + }, + extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue'], + }, + build: { + target: 'es2015', + minify: 'terser', + sourcemap: true, + rollupOptions: { + output: { + manualChunks: { + vendor: ['react', 'react-dom'], + }, + }, + }, + }, + server: { + port: 3000, + open: true, + proxy: { + '/api': 'http://localhost:8080', + }, + }, +}); +``` + +✅ **GOOD** - Lean and focused `vite.config.js` +```javascript +// vite.config.js +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + // Only add resolve.alias if absolutely needed for complex paths. + // Avoid resolve.extensions unless you have specific non-standard file types. + // Vite's defaults are usually sufficient. +}); +``` + +### Use Explicit File Extensions +Relying on `resolve.extensions` for implicit imports forces Vite to perform multiple filesystem checks, slowing down resolution. Be explicit. + +❌ **BAD** - Implicit import +```javascript +// src/components/MyComponent.jsx +import { util } from '../utils'; // Vite has to guess .js, .ts, .jsx etc. +``` + +✅ **GOOD** - Explicit import +```javascript +// src/components/MyComponent.jsx +import { util } from '../utils/index.js'; // Or .ts, .jsx, etc. +``` + +### Avoid Barrel Files +Barrel files (e.g., `index.js` re-exporting many modules) force Vite to fetch and transform all re-exported files, even if only one API is used. This hurts initial page load performance. + +❌ **BAD** - Barrel file (`src/utils/index.js`) +```javascript +// src/utils/index.js +export * from './color.js'; +export * from './dom.js'; +export * from './slash.js'; + +// src/app.js +import { slash } from './utils'; // Loads color.js, dom.js, and slash.js +``` + +✅ **GOOD** - Direct imports +```javascript +// src/app.js +import { slash } from './utils/slash.js'; // Only loads slash.js +``` + +## 2. Common Patterns and Anti-patterns + +### Embrace Native ES Modules +Vite is built on native ES Modules. Always write your client-side code using `import`/`export` syntax. + +❌ **BAD** - CommonJS in client-side code +```javascript +// main.js +const myModule = require('./my-module'); // Will fail in browser +``` + +✅ **GOOD** - Native ES Modules +```javascript +// main.js +import myModule from './my-module.js'; +``` + +### Use `import.meta.env` for Environment Variables +Vite injects environment variables via `import.meta.env`. This is the correct way to access them in client-side code. `process.env` is for Node.js environments. + +❌ **BAD** - Using `process.env` in client code +```javascript +// app.js +console.log(process.env.VITE_API_URL); // `process` is not defined in browser +``` + +✅ **GOOD** - Using `import.meta.env` +```javascript +// app.js +console.log(import.meta.env.VITE_API_URL); // Correctly accesses Vite env vars +``` + +### Optimize with Dynamic Imports +For large components or libraries, use dynamic imports to load them only when needed, reducing initial bundle size and improving load times. + +❌ **BAD** - Eagerly loading large component +```javascript +// App.jsx +import LargeComponent from './LargeComponent'; // Always bundled +function App() { + return ; +} +``` + +✅ **GOOD** - Dynamically importing +```javascript +// App.jsx (React example) +import { lazy, Suspense } from 'react'; +const LargeComponent = lazy(() => import('./LargeComponent')); + +function App() { + return ( + Loading...
}> + + + ); +} +``` + +## 3. Performance Considerations + +### Audit Custom Plugins +Community plugins can introduce performance bottlenecks. Profile them using Vite's debug flags. + +❌ **BAD** - Blindly adding plugins +```javascript +// vite.config.js +import { defineConfig } from 'vite'; +import someHeavyPlugin from 'some-heavy-plugin'; // No profiling done + +export default defineConfig({ + plugins: [someHeavyPlugin()], +}); +``` + +✅ **GOOD** - Profiling plugins +```bash +# Run Vite with debug flags to identify slow plugins +vite --debug plugin-transform +``` +Use `vite-plugin-inspect` to visualize the transform pipeline. + +### Optimize Browser Setup +Browser extensions and disabled cache settings can severely impact dev server performance. + +❌ **BAD** - Developing with "Disable Cache" enabled in dev tools. +``` +// Browser Dev Tools -> Network tab -> "Disable Cache" checked +``` + +✅ **GOOD** - Use a clean browser profile or incognito mode. +Ensure "Disable Cache" is **unchecked** in dev tools. + +### Warm Up Critical Files +For complex applications, pre-warming frequently used files can prevent request waterfalls. + +❌ **BAD** - Relying solely on on-demand transformation for critical paths. +```javascript +// No explicit warmup configured +``` + +✅ **GOOD** - Use `server.warmup` in `vite.config.js` +```javascript +// vite.config.js +import { defineConfig } from 'vite'; + +export default defineConfig({ + server: { + warmup: { + clientFiles: ['./src/main.js', './src/App.jsx'], + // Or use patterns: ['**/*.vue', '**/*.jsx'] + }, + }, +}); +``` + +## 4. Common Pitfalls and Gotchas + +### Incorrect Base Path for Deployment +When deploying to a sub-path (e.g., `yourdomain.com/my-app/`), ensure `base` is correctly configured. + +❌ **BAD** - Hardcoding absolute paths or missing `base` +```javascript +// vite.config.js +// Default base: '/' +// Assets might break when deployed to a sub-path +``` + +✅ **GOOD** - Configure `base` for sub-path deployments +```javascript +// vite.config.js +import { defineConfig } from 'vite'; + +export default defineConfig({ + base: '/my-app/', // For deploying to https://yourdomain.com/my-app/ + // Or use './' for relative paths if the base is unknown at build time + // base: './', +}); +``` +Access the base path in your code via `import.meta.env.BASE_URL`. + +### Mismanaging `NODE_ENV` with API Usage +When using Vite's JS API (`createServer`, `build`) in the same Node.js process, ensure `process.env.NODE_ENV` or the `mode` config option is consistent to prevent conflicts. + +❌ **BAD** - Conflicting `NODE_ENV` +```javascript +// script.js +process.env.NODE_ENV = 'production'; +await createServer(); // Might behave unexpectedly +``` + +✅ **GOOD** - Explicitly set `mode` or spawn child processes +```javascript +// script.js +import { createServer } from 'vite'; +// Option 1: Explicitly set mode +const devServer = await createServer({ mode: 'development' }); +await devServer.listen(); + +// Option 2: Spawn child processes for separate contexts +// (e.g., one for dev server, one for build) +``` + +## 5. Testing Approaches + +### Standardize on Vitest +Vitest is the official testing framework for Vite projects, offering seamless integration with Vite's configuration and plugin ecosystem. + +❌ **BAD** - Using a separate test runner (e.g., Jest) that requires its own complex configuration. +```json +// package.json +"scripts": { + "test": "jest" // Requires separate Babel/Webpack config +} +``` + +✅ **GOOD** - Integrate Vitest directly into `vite.config.ts` +```typescript +// vite.config.ts +/// +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, // For global APIs like `describe`, `it`, `expect` + environment: 'jsdom', // Or 'node' + setupFiles: './src/setupTests.js', // Global setup for tests + }, +}); +``` \ No newline at end of file diff --git a/.cursor/rules/vitest-best-practices.mdc b/.cursor/rules/vitest-best-practices.mdc new file mode 100644 index 0000000..34465d7 --- /dev/null +++ b/.cursor/rules/vitest-best-practices.mdc @@ -0,0 +1,251 @@ +--- +description: Opinionated best practices for fast, reliable Vitest unit and integration tests in JS/TS projects. +globs: **/*.{js,ts,jsx,tsx} +alwaysApply: false +--- + +# Vitest Best Practices + +Vitest is the definitive testing framework for our Vite-powered projects. It offers a fast, Jest-compatible API with deep integration into the Vite ecosystem. Adhering to these guidelines ensures our tests are robust, performant, and easy to maintain. + +## 1. Code Organization & Naming + +**Always co-locate test files with their source.** This improves discoverability and ensures tests are updated alongside their implementation. + +* **File Naming**: Use `*.test.{ts,tsx,js,jsx}`. +* **Location**: Place test files directly next to the component or module they test. + +❌ BAD: +``` +// src/components/Button/Button.tsx +// tests/components/Button.test.tsx +``` + +✅ GOOD: +```typescript +// src/components/Button/Button.tsx +// src/components/Button/Button.test.tsx +``` + +## 2. Test Structure & Isolation + +**Organize tests logically using `describe` and `it` (or `test`) blocks.** Ensure each test is isolated and deterministic. + +* **`describe`**: Group related tests into suites. +* **`it` / `test`**: Define individual test cases. Prefer `it` for consistency with Jest. +* **Hooks (`beforeEach`, `afterEach`)**: Use these for setup and teardown to ensure test isolation. + +❌ BAD: (Shared state, no cleanup) +```typescript +let user; +test('creates user', () => { + user = createUser(); + expect(user).toBeDefined(); +}); +test('updates user', () => { // Depends on previous test + user.name = 'New Name'; + expect(user.name).toBe('New Name'); +}); +``` + +✅ GOOD: (Isolated tests with hooks) +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createUser, deleteUser } from './user-service'; + +describe('User Service', () => { + let user; + + beforeEach(() => { + user = createUser(); // Create a fresh user for each test + }); + + afterEach(() => { + deleteUser(user.id); // Clean up after each test + }); + + it('should create a user', () => { + expect(user).toBeDefined(); + expect(user.id).toBeTypeOf('string'); + }); + + it('should update a user', () => { + user.name = 'Jane Doe'; + expect(user.name).toBe('Jane Doe'); + }); +}); +``` + +## 3. Asynchronous Testing with `vi.waitFor` + +**Always use `vi.waitFor` for polling conditions in asynchronous tests.** Avoid arbitrary `setTimeout` calls or manual polling loops. `vi.waitFor` is designed for reliable synchronization. + +❌ BAD: (Flaky, relies on arbitrary timeout) +```typescript +test('data loads after delay', async () => { + let data = null; + fetchData().then(res => (data = res)); + await new Promise(resolve => setTimeout(resolve, 100)); // Arbitrary wait + expect(data).toEqual('some data'); +}); +``` + +✅ GOOD: (Reliable polling with `vi.waitFor`) +```typescript +import { it, expect, vi } from 'vitest'; +import { fetchData } from './api'; // Assume fetchData returns a Promise + +it('should load data after delay', async () => { + let data = null; + fetchData().then(res => (data = res)); + + // Polls until data is not null, with a 2-second timeout + await vi.waitFor(() => expect(data).not.toBeNull(), { timeout: 2000 }); + + expect(data).toEqual('some data'); +}); +``` + +## 4. Mocking Strategies + +**Leverage Vitest's `vi` API for all mocking.** This provides Jest-compatible syntax and seamless integration. Always clean up mocks after each test. + +* **`vi.fn()`**: Mock individual functions. +* **`vi.spyOn()`**: Spy on existing object methods. +* **`vi.mock()`**: Mock entire modules. + +### Function Mocking + +❌ BAD: (Manual mock, no easy reset) +```typescript +const originalFetch = global.fetch; +global.fetch = () => Promise.resolve({ json: () => ({ id: 1 }) }); +// ... test ... +global.fetch = originalFetch; // Easy to forget cleanup +``` + +✅ GOOD: (Using `vi.fn` with `afterEach` cleanup) +```typescript +import { it, expect, vi, afterEach } from 'vitest'; +import { getUser } from './user-api'; + +// Mock the module containing fetchUser +vi.mock('./user-api', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + fetchUser: vi.fn(), // Mock specific function within the module + }; +}); + +// Import the mocked function after vi.mock +import { fetchUser } from './user-api'; + +afterEach(() => { + vi.clearAllMocks(); // Clear mock calls after each test to prevent state leakage +}); + +it('should fetch user data', async () => { + fetchUser.mockResolvedValueOnce({ id: 1, name: 'Test User' }); + const user = await getUser(1); + expect(fetchUser).toHaveBeenCalledWith(1); + expect(user.name).toBe('Test User'); +}); +``` + +### Module Mocking + +**Mock modules at the top of the file.** This ensures the mock is applied before the module under test imports it. + +✅ GOOD: (Module mock before imports) +```typescript +import { vi, it, expect } from 'vitest'; + +// Mock the entire 'lodash' module to control its behavior +vi.mock('lodash', () => ({ + debounce: vi.fn((fn) => fn), // Mock debounce to execute immediately +})); + +import { debounce } from 'lodash'; // Import the mocked debounce +import { saveInput } from './input-handler'; // Module using debounce + +it('should call save function without debounce delay', () => { + saveInput('test'); + expect(debounce).toHaveBeenCalledOnce(); +}); +``` + +## 5. DOM Environment & Component Testing + +**Use `happy-dom` for lightweight DOM environments.** It's generally faster and sufficient for most component tests. Switch to `jsdom` only if specific browser APIs are missing in `happy-dom`. + +* Configure in `vite.config.ts` or `vitest.config.ts`. + +✅ GOOD: (Configuring `happy-dom`) +```typescript +// vite.config.ts or vitest.config.ts +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'happy-dom', // Use happy-dom for faster DOM mocking + globals: true, // Auto-import test APIs globally (e.g., describe, it, expect) + }, +}); +``` + +## 6. Performance & Concurrent Tests + +**Utilize `.concurrent` for tests that can run in parallel.** This significantly speeds up test suites where tests are independent. + +* Use `it.concurrent` for individual tests. +* Use `describe.concurrent` for entire suites. +* **Important**: When using `.concurrent`, always destructure `expect` from the test context to avoid issues with snapshot and assertion tracking. + +❌ BAD: (Sequential tests, slow) +```typescript +describe('My Feature', () => { + it('test A', async () => { /* ... */ }); + it('test B', async () => { /* ... */ }); +}); +``` + +✅ GOOD: (Concurrent tests, faster) +```typescript +import { describe, it } from 'vitest'; + +describe.concurrent('My Feature', () => { + it('test A', async ({ expect }) => { // Destructure expect for concurrent tests + expect(1).toBe(1); + }); + + it.concurrent('test B', async ({ expect }) => { // Destructure expect + expect(2).toBe(2); + }); +}); +``` + +## 7. Code Coverage + +**Enable V8-based code coverage.** It offers near-zero overhead and integrates seamlessly. + +* Add `coverage` configuration to `vite.config.ts` or `vitest.config.ts`. +* Run with `vitest run --coverage`. + +✅ GOOD: (V8 coverage configuration) +```typescript +// vite.config.ts or vitest.config.ts +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'happy-dom', + globals: true, + coverage: { + provider: 'v8', // Use V8 for native, fast coverage + reporter: ['text', 'json', 'html'], // Output formats for reports + exclude: ['node_modules/', 'dist/', '.eslintrc.cjs'], // Exclude common directories from coverage + }, + }, +}); +``` diff --git a/.cursor/settings.json b/.cursor/settings.json new file mode 100644 index 0000000..5a97eaf --- /dev/null +++ b/.cursor/settings.json @@ -0,0 +1,7 @@ +{ + "plugins": { + "cloudflare": { + "enabled": true + } + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0116740 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.cursor +data +**/target +**/node_modules +frontend/dist +*.db +*.db-shm +*.db-wal +.env +.env.* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8cb1934 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Cloudflare API +CLOUDFLARE_API_TOKEN= + +# Database +DATABASE_URL=sqlite:data/app.db + +# Auth +JWT_SECRET=dev-secret-change-me +JWT_TTL_HOURS=24 +ADMIN_USERNAME=admin +# Leave empty for dev default password "admin" +ADMIN_PASSWORD_HASH= + +# Server +SERVER_PORT=8080 +STATIC_DIR= +RUST_LOG=info + +# Certificate scheduler (cron) +CERT_CHECK_CRON=0 */6 * * * diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml new file mode 100644 index 0000000..6b05d42 --- /dev/null +++ b/.gitea/workflows/docker.yml @@ -0,0 +1,166 @@ +name: Build, Test, and Push CFDM Docker Image + +on: + push: + branches: [main, develop, 'feature/**', 'release/**', 'hotfix/**'] + tags: ['v*'] + paths: ['**'] + pull_request: + branches: [main, develop] + paths: ['**'] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Build test stage + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.test + load: true + tags: cfdm:test + provenance: false + + - name: Run tests + run: docker run --rm cfdm:test + + build-and-push: + needs: test + if: startsWith(gitea.ref, 'refs/tags/v') || (gitea.ref_name == 'main' && gitea.event_name == 'push') + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Gitea Registry + uses: docker/login-action@v3 + with: + registry: git.shts.su + username: ${{ gitea.actor }} + password: ${{ secrets.PACKAGE_TOKEN }} + + - name: Create version file + run: | + VERSION=$(cat VERSION) + BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') + BRANCH="${{ gitea.ref_name }}" + echo "APP_VERSION=${VERSION}" > ./version.txt + echo "BUILD_DATE=${BUILD_DATE}" >> ./version.txt + echo "GIT_BRANCH=${BRANCH}" >> ./version.txt + echo "GIT_COMMIT=${{ gitea.sha }}" >> ./version.txt + echo "GIT_COMMIT_SHORT=$(echo ${{ gitea.sha }} | cut -c1-7)" >> ./version.txt + echo "BUILD_TIMESTAMP=$(date -u +%s)" >> ./version.txt + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: git.shts.su/${{ gitea.repository }} + tags: | + type=semver,pattern={{version}} + type=raw,value=latest,enable=${{ gitea.ref_name == 'main' }} + type=sha,prefix={{date 'YYYYMMDD'}}-,enable=${{ gitea.ref_name == 'main' }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=git.shts.su/${{ gitea.repository }}:buildcache + cache-to: type=registry,ref=git.shts.su/${{ gitea.repository }}:buildcache,mode=max + provenance: true + + create-release: + needs: build-and-push + if: startsWith(gitea.ref, 'refs/tags/v') + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate changelog + id: changelog + run: | + LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + if [[ -n "$LAST_TAG" ]]; then + CHANGELOG=$(git log --pretty=format:"- **%h** %s (%an, %ar)" --no-merges ${LAST_TAG}..HEAD 2>/dev/null || echo "") + else + CHANGELOG=$(git log --pretty=format:"- **%h** %s (%an, %ar)" --no-merges -10 2>/dev/null || echo "") + fi + if [[ -z "$CHANGELOG" ]]; then CHANGELOG="- No changes detected"; fi + echo "CHANGELOG<> $GITEA_OUTPUT + echo "$CHANGELOG" >> $GITEA_OUTPUT + echo "EOF" >> $GITEA_OUTPUT + + - name: Create Release + run: | + VERSION="${{ gitea.ref_name }}" + RELEASE_DATA=$(cat <> $GITEA_OUTPUT + else + echo "changed=true" >> $GITEA_OUTPUT + fi + + - name: Clone Wiki repository + if: steps.check_changes.outputs.changed == 'true' + run: | + WIKI_URL=$(echo "${{ gitea.server_url }}/${{ gitea.repository }}.wiki.git" | sed -e "s|://|://gitea-actions:${{ secrets.GITEA_TOKEN }}@|") + git clone "${WIKI_URL}" cfdm.wiki + + - name: Update and push Wiki content + if: steps.check_changes.outputs.changed == 'true' + run: | + cp docs/Home.md cfdm.wiki/Home.md + cd cfdm.wiki + git config user.name "Gitea Actions" + git config user.email "actions@gitea" + git add Home.md + git diff --staged --quiet || git commit -m "docs: Update Wiki from main repository" + git push diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24a8ef5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Rust +backend/target/ +**/*.rs.bk + +# Node +frontend/node_modules/ +frontend/dist/ +frontend/.tanstack/ + +# Data +data/ +*.db +*.db-shm +*.db-wal + +# Env +.env +.env.local + +# IDE +.idea/ +.vscode/ +*.swp + +# OS +.DS_Store +Thumbs.db + +# Build +version.txt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7b49e20 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,59 @@ +# Contributing + +## Gitflow + +- **main** — production; merges only from `release/*` and `hotfix/*` +- **develop** — integration branch (default for development) +- **feature/\*** — `feature/[issue-id]-description` from `develop` +- **release/vX.Y.Z** — stabilization from `develop` +- **hotfix/vX.Y.Z** — urgent fixes from `main` + +## Commits + +Format: `type(scope): description` + +Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` + +## Pull requests + +- Target `develop` for features; `main` for release/hotfix +- CI `test` job must pass +- At least one approval +- Rebase/merge when up to date with target branch +- Delete branch after merge + +## Versioning + +- SemVer in [`VERSION`](VERSION) +- Bump version in `release/*` or `hotfix/*` branches +- Tag `vX.Y.Z` on `main` after release merge + +## Branch protection (Gitea) + +Configure for **main** and **develop**: + +- Require pull request reviews +- Require status checks (`test`) +- Require branches to be up to date +- No force push, no deletion + +## Local development + +```bash +# Backend +cd backend +cp ../.env.example ../.env +cargo run + +# Frontend (separate terminal) +cd frontend +npm install +npm run dev +``` + +## Release process + +1. `git checkout -b release/v0.2.0 develop` +2. Bump `VERSION`, fix release issues +3. PR to `main` → merge → tag `v0.2.0` +4. Merge release branch back to `develop` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8db3cdf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-bookworm-slim AS frontend-builder +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +FROM rust:1.85-bookworm AS backend-builder +WORKDIR /app +RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* +COPY backend/Cargo.toml backend/Cargo.lock* ./backend/ +COPY backend/migrations ./backend/migrations/ +COPY backend/src ./backend/src/ +COPY --from=frontend-builder /app/frontend/dist ./static/ +WORKDIR /app/backend +ENV STATIC_DIR=/app/static +RUN cargo build --release + +FROM debian:bookworm-slim AS runtime +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=backend-builder /app/backend/target/release/cfdm-backend /app/cfdm-backend +COPY --from=frontend-builder /app/frontend/dist /app/static +COPY VERSION /app/VERSION +ENV STATIC_DIR=/app/static +ENV DATABASE_URL=sqlite:/data/app.db +ENV SERVER_PORT=8080 +EXPOSE 8080 +VOLUME ["/data"] +CMD ["/app/cfdm-backend"] diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..9fe073a --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,22 @@ +# syntax=docker/dockerfile:1 + +FROM rust:1.85-bookworm AS test +WORKDIR /app +RUN apt-get update && apt-get install -y pkg-config libssl-dev curl && rm -rf /var/lib/apt/lists/* + +# Node for frontend tests +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs + +COPY backend/Cargo.toml backend/Cargo.lock* ./backend/ +COPY backend/migrations ./backend/migrations/ +COPY backend/src ./backend/src/ +WORKDIR /app/backend +RUN cargo test --release + +COPY frontend/package.json frontend/package-lock.json /app/frontend/ +WORKDIR /app/frontend +RUN npm ci +COPY frontend/ /app/frontend/ +RUN npm run test + +CMD ["sh", "-c", "cd /app/backend && cargo test && cd /app/frontend && npm run test"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..69199fd --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Cloudflare Domain Manager + +Self-hosted service for managing domains, DNS records, and SSL certificates via Cloudflare API. + +## Features + +- Domain and DNS record management (hybrid sync with Cloudflare) +- Domain groups (local / vpn / external) and service tags +- SSL certificate expiry monitoring +- React UI with TanStack Router & Query +- Single Docker container deployment + +## Quick start + +```bash +cp .env.example .env +# Edit CLOUDFLARE_API_TOKEN, JWT_SECRET + +docker compose up -d +``` + +Open http://localhost:8080 — default login `admin` / `admin` (dev only). + +## Stack + +- **Backend:** Rust, Axum, sqlx, SQLite +- **Frontend:** React, Vite, TanStack Router/Query/Table, shadcn-style UI, Recharts +- **CI:** Gitea Actions (Gitflow) + +## Documentation + +See [docs/Home.md](docs/Home.md) and [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..ce65c99 --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "cfdm-backend" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { version = "0.8", features = ["macros"] } +tokio = { version = "1", features = ["full"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["cors", "trace", "fs"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "chrono", "migrate"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +thiserror = "2" +chrono = { version = "0.4", features = ["serde"] } +dotenvy = "0.15" +jsonwebtoken = "9" +argon2 = "0.5" +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +tokio-cron-scheduler = "0.14" +rustls = { version = "0.23", features = ["ring"] } +tokio-rustls = "0.26" +webpki-roots = "0.26" +x509-parser = "0.16" +uuid = { version = "1", features = ["v4"] } +regex = "1" + +[dev-dependencies] +tokio-test = "0.4" diff --git a/backend/migrations/001_initial.sql b/backend/migrations/001_initial.sql new file mode 100644 index 0000000..38b0c78 --- /dev/null +++ b/backend/migrations/001_initial.sql @@ -0,0 +1,110 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE services ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE domains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL, + zone_name TEXT NOT NULL UNIQUE, + cf_zone_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + last_synced_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_domains_cf_zone_id ON domains(cf_zone_id); +CREATE INDEX idx_domains_group_id ON domains(group_id); + +CREATE TABLE subdomains ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE, + name TEXT NOT NULL, + fqdn TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(domain_id, name) +); + +CREATE INDEX idx_subdomains_domain_id ON subdomains(domain_id); + +CREATE TABLE dns_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE, + cf_record_id TEXT, + record_type TEXT NOT NULL, + name TEXT NOT NULL, + content TEXT NOT NULL, + ttl INTEGER NOT NULL DEFAULT 1, + proxied INTEGER NOT NULL DEFAULT 0, + priority INTEGER, + sync_status TEXT NOT NULL DEFAULT 'pending_push', + origin TEXT NOT NULL DEFAULT 'local', + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_dns_records_domain_type_name ON dns_records(domain_id, record_type, name); +CREATE INDEX idx_dns_records_sync_status ON dns_records(sync_status); + +CREATE TABLE domain_services ( + domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE, + service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE, + PRIMARY KEY (domain_id, service_id) +); + +CREATE TABLE subdomain_services ( + subdomain_id INTEGER NOT NULL REFERENCES subdomains(id) ON DELETE CASCADE, + service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE, + PRIMARY KEY (subdomain_id, service_id) +); + +CREATE TABLE certificates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE, + subdomain_id INTEGER REFERENCES subdomains(id) ON DELETE SET NULL, + hostname TEXT NOT NULL UNIQUE, + expires_at TEXT, + last_checked_at TEXT, + last_error TEXT, + status TEXT NOT NULL DEFAULT 'unknown', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_certificates_status_expires ON certificates(status, expires_at); + +CREATE TABLE sync_jobs ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL DEFAULT 'pending', + domain_id INTEGER REFERENCES domains(id) ON DELETE SET NULL, + message TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + finished_at TEXT +); + +INSERT INTO groups (name, slug) VALUES + ('Local', 'local'), + ('VPN', 'vpn'), + ('External', 'external'); + +INSERT INTO services (name, slug) VALUES + ('DNS', 'dns'), + ('BGP', 'bgp'), + ('CDN', 'cdn'), + ('Mail', 'mail'); diff --git a/backend/src/api/handlers/auth.rs b/backend/src/api/handlers/auth.rs new file mode 100644 index 0000000..efe8f0d --- /dev/null +++ b/backend/src/api/handlers/auth.rs @@ -0,0 +1,35 @@ +use axum::{ + extract::State, + http::{header::AUTHORIZATION, Request}, + middleware::Next, + response::Response, +}; +use crate::error::AppError; +use crate::services::auth::{self, LoginRequest}; +use crate::state::AppState; + +pub async fn login( + State(state): State, + axum::Json(body): axum::Json, +) -> Result, AppError> { + let resp = auth::login(&state.config, body)?; + Ok(axum::Json(resp)) +} + +pub async fn require_auth( + State(state): State, + req: Request, + next: Next, +) -> Result { + let auth_header = req + .headers() + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let token = auth_header.strip_prefix("Bearer ").unwrap_or(""); + if token.is_empty() { + return Err(AppError::Unauthorized); + } + auth::validate_token(&state.config, token)?; + Ok(next.run(req).await) +} diff --git a/backend/src/api/handlers/certificates.rs b/backend/src/api/handlers/certificates.rs new file mode 100644 index 0000000..fa51b13 --- /dev/null +++ b/backend/src/api/handlers/certificates.rs @@ -0,0 +1,39 @@ +use crate::error::AppResult; +use crate::services::certificate_service; +use crate::state::AppState; +use axum::extract::{Path, Query, State}; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct CertListQuery { + pub status: Option, +} + +pub async fn list( + State(state): State, + Query(q): Query, +) -> AppResult>> { + Ok(axum::Json( + certificate_service::list_certificates(&state.pool, q.status.as_deref()).await?, + )) +} + +pub async fn get_one( + State(state): State, + Path(id): Path, +) -> AppResult> { + Ok(axum::Json(certificate_service::get_certificate(&state.pool, id).await?)) +} + +pub async fn check_all( + State(state): State, +) -> AppResult> { + let count = certificate_service::run_all_checks(&state.pool).await?; + Ok(axum::Json(serde_json::json!({ "checked": count }))) +} + +pub async fn summary( + State(state): State, +) -> AppResult>> { + Ok(axum::Json(certificate_service::status_summary(&state.pool).await?)) +} diff --git a/backend/src/api/handlers/dns.rs b/backend/src/api/handlers/dns.rs new file mode 100644 index 0000000..5e68e4c --- /dev/null +++ b/backend/src/api/handlers/dns.rs @@ -0,0 +1,96 @@ +use crate::error::AppResult; +use crate::repositories::dns_records::DnsListFilter; +use crate::services::dns_service::{self, BulkDnsOp, CreateDnsRequest, ResolveDnsRequest, UpdateDnsRequest}; +use crate::state::AppState; +use axum::extract::{Path, Query, State}; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct DnsListQuery { + pub record_type: Option, + pub name: Option, + pub content: Option, + pub proxied: Option, + pub sync_status: Option, + pub q: Option, + pub sort: Option, + pub page: Option, + pub limit: Option, +} + +#[derive(Deserialize)] +pub struct BulkBody { + pub operations: Vec, +} + +pub async fn list( + State(state): State, + Path(id): Path, + Query(q): Query, +) -> AppResult>> { + let filter = DnsListFilter { + record_type: q.record_type, + name: q.name, + content: q.content, + proxied: q.proxied, + sync_status: q.sync_status, + q: q.q, + sort: q.sort.unwrap_or_else(|| "name".into()), + page: q.page.unwrap_or(1), + limit: q.limit.unwrap_or(50), + }; + Ok(axum::Json(dns_service::list(&state.pool, id, filter).await?)) +} + +pub async fn get_one( + State(state): State, + Path((id, record_id)): Path<(i64, i64)>, +) -> AppResult> { + Ok(axum::Json(dns_service::get(&state.pool, id, record_id).await?)) +} + +pub async fn create( + State(state): State, + Path(id): Path, + axum::Json(body): axum::Json, +) -> AppResult> { + Ok(axum::Json(dns_service::create(&state.pool, &state.cf, id, body).await?)) +} + +pub async fn update( + State(state): State, + Path((id, record_id)): Path<(i64, i64)>, + axum::Json(body): axum::Json, +) -> AppResult> { + Ok(axum::Json( + dns_service::update(&state.pool, &state.cf, id, record_id, body).await?, + )) +} + +pub async fn delete( + State(state): State, + Path((id, record_id)): Path<(i64, i64)>, +) -> AppResult> { + dns_service::delete_record(&state.pool, &state.cf, id, record_id).await?; + Ok(axum::Json(serde_json::json!({ "deleted": true }))) +} + +pub async fn bulk( + State(state): State, + Path(id): Path, + axum::Json(body): axum::Json, +) -> AppResult>> { + Ok(axum::Json( + dns_service::bulk(&state.pool, &state.cf, id, body.operations).await?, + )) +} + +pub async fn resolve( + State(state): State, + Path((id, record_id)): Path<(i64, i64)>, + axum::Json(body): axum::Json, +) -> AppResult> { + Ok(axum::Json( + dns_service::resolve_conflict(&state.pool, &state.cf, id, record_id, body).await?, + )) +} diff --git a/backend/src/api/handlers/domains.rs b/backend/src/api/handlers/domains.rs new file mode 100644 index 0000000..16c687a --- /dev/null +++ b/backend/src/api/handlers/domains.rs @@ -0,0 +1,93 @@ +use crate::error::AppResult; +use crate::services::domain_service; +use crate::state::AppState; +use axum::extract::{Path, Query, State}; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct DomainListQuery { + pub group_id: Option, +} + +#[derive(Deserialize)] +pub struct CreateDomainBody { + pub zone_name: String, + pub group_id: Option, +} + +#[derive(Deserialize)] +pub struct UpdateDomainBody { + pub group_id: Option, + pub status: Option, +} + +#[derive(Deserialize)] +pub struct SetServicesBody { + pub service_ids: Vec, +} + +pub async fn list( + State(state): State, + Query(q): Query, +) -> AppResult>> { + Ok(axum::Json(domain_service::list_domains(&state.pool, q.group_id).await?)) +} + +pub async fn get_one( + State(state): State, + Path(id): Path, +) -> AppResult> { + Ok(axum::Json(domain_service::get_domain(&state.pool, id).await?)) +} + +pub async fn create( + State(state): State, + axum::Json(body): axum::Json, +) -> AppResult> { + Ok(axum::Json( + domain_service::create_domain( + &state.pool, + &state.cf, + body.group_id, + &body.zone_name, + ) + .await?, + )) +} + +pub async fn update( + State(state): State, + Path(id): Path, + axum::Json(body): axum::Json, +) -> AppResult> { + let existing = domain_service::get_domain(&state.pool, id).await?; + let status = body.status.unwrap_or(existing.status); + Ok(axum::Json( + domain_service::update_domain(&state.pool, id, body.group_id, &status).await?, + )) +} + +pub async fn delete( + State(state): State, + Path(id): Path, +) -> AppResult> { + domain_service::delete_domain(&state.pool, id).await?; + Ok(axum::Json(serde_json::json!({ "deleted": true }))) +} + +pub async fn import_zone( + State(state): State, + Path(id): Path, +) -> AppResult> { + let count = domain_service::import_zone_records(&state.pool, &state.cf, id).await?; + Ok(axum::Json(serde_json::json!({ "imported": count }))) +} + +pub async fn set_services( + State(state): State, + Path(id): Path, + axum::Json(body): axum::Json, +) -> AppResult> { + let ids = domain_service::set_domain_services(&state.pool, id, body.service_ids).await?; + Ok(axum::Json(serde_json::json!({ "service_ids": ids }))) +} diff --git a/backend/src/api/handlers/groups.rs b/backend/src/api/handlers/groups.rs new file mode 100644 index 0000000..b96907b --- /dev/null +++ b/backend/src/api/handlers/groups.rs @@ -0,0 +1,49 @@ +use crate::error::AppResult; +use crate::services::group_service; +use crate::state::AppState; +use axum::extract::{Path, State}; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct GroupBody { + pub name: String, + pub slug: String, +} + +pub async fn list(State(state): State) -> AppResult>> { + Ok(axum::Json(group_service::list_groups(&state.pool).await?)) +} + +pub async fn get_one( + State(state): State, + Path(id): Path, +) -> AppResult> { + Ok(axum::Json(crate::repositories::groups::get(&state.pool, id).await?)) +} + +pub async fn create( + State(state): State, + axum::Json(body): axum::Json, +) -> AppResult> { + Ok(axum::Json( + group_service::create_group(&state.pool, &body.name, &body.slug).await?, + )) +} + +pub async fn update( + State(state): State, + Path(id): Path, + axum::Json(body): axum::Json, +) -> AppResult> { + Ok(axum::Json( + group_service::update_group(&state.pool, id, &body.name, &body.slug).await?, + )) +} + +pub async fn delete( + State(state): State, + Path(id): Path, +) -> AppResult> { + group_service::delete_group(&state.pool, id).await?; + Ok(axum::Json(serde_json::json!({ "deleted": true }))) +} diff --git a/backend/src/api/handlers/health.rs b/backend/src/api/handlers/health.rs new file mode 100644 index 0000000..b6f4051 --- /dev/null +++ b/backend/src/api/handlers/health.rs @@ -0,0 +1,27 @@ +use crate::error::AppResult; +use crate::state::AppState; +use axum::extract::State; +use serde_json::json; + +pub async fn health(State(state): State) -> AppResult> { + sqlx::query_scalar::<_, i32>("SELECT 1") + .fetch_one(&state.pool) + .await?; + Ok(axum::Json(json!({ "status": "ok" }))) +} + +pub async fn ready(State(state): State) -> AppResult> { + sqlx::query_scalar::<_, i32>("SELECT 1") + .fetch_one(&state.pool) + .await?; + let cf_ok = if state.config.cloudflare_api_token.is_empty() { + false + } else { + state.cf.list_zones().await.is_ok() + }; + Ok(axum::Json(json!({ + "status": if cf_ok { "ready" } else { "degraded" }, + "database": true, + "cloudflare": cf_ok, + }))) +} diff --git a/backend/src/api/handlers/mod.rs b/backend/src/api/handlers/mod.rs new file mode 100644 index 0000000..27f3570 --- /dev/null +++ b/backend/src/api/handlers/mod.rs @@ -0,0 +1,9 @@ +pub mod auth; +pub mod certificates; +pub mod dns; +pub mod domains; +pub mod groups; +pub mod health; +pub mod services; +pub mod subdomains; +pub mod sync; diff --git a/backend/src/api/handlers/services.rs b/backend/src/api/handlers/services.rs new file mode 100644 index 0000000..9f1ed61 --- /dev/null +++ b/backend/src/api/handlers/services.rs @@ -0,0 +1,45 @@ +use crate::error::AppResult; +use crate::repositories::services as service_repo; +use crate::state::AppState; +use axum::extract::{Path, State}; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct ServiceBody { + pub name: String, + pub slug: String, +} + +pub async fn list(State(state): State) -> AppResult>> { + Ok(axum::Json(service_repo::list(&state.pool).await?)) +} + +pub async fn get_one( + State(state): State, + Path(id): Path, +) -> AppResult> { + Ok(axum::Json(service_repo::get(&state.pool, id).await?)) +} + +pub async fn create( + State(state): State, + axum::Json(body): axum::Json, +) -> AppResult> { + Ok(axum::Json(service_repo::create(&state.pool, &body.name, &body.slug).await?)) +} + +pub async fn update( + State(state): State, + Path(id): Path, + axum::Json(body): axum::Json, +) -> AppResult> { + Ok(axum::Json(service_repo::update(&state.pool, id, &body.name, &body.slug).await?)) +} + +pub async fn delete( + State(state): State, + Path(id): Path, +) -> AppResult> { + service_repo::delete(&state.pool, id).await?; + Ok(axum::Json(serde_json::json!({ "deleted": true }))) +} diff --git a/backend/src/api/handlers/subdomains.rs b/backend/src/api/handlers/subdomains.rs new file mode 100644 index 0000000..cda7a07 --- /dev/null +++ b/backend/src/api/handlers/subdomains.rs @@ -0,0 +1,59 @@ +use crate::error::AppResult; +use crate::repositories::subdomains as sub_repo; +use crate::state::AppState; +use axum::extract::{Path, State}; +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct SubdomainBody { + pub name: String, +} + +pub async fn list( + State(state): State, + Path(domain_id): Path, +) -> AppResult>> { + Ok(axum::Json(sub_repo::list_by_domain(&state.pool, domain_id).await?)) +} + +pub async fn get_one( + State(state): State, + Path(id): Path, +) -> AppResult> { + Ok(axum::Json(sub_repo::get(&state.pool, id).await?)) +} + +pub async fn create( + State(state): State, + Path(domain_id): Path, + axum::Json(body): axum::Json, +) -> AppResult> { + let domain = crate::repositories::domains::get(&state.pool, domain_id).await?; + let fqdn = if body.name == "@" { + domain.zone_name.clone() + } else { + format!("{}.{}", body.name, domain.zone_name) + }; + Ok(axum::Json( + sub_repo::create(&state.pool, domain_id, &body.name, &fqdn).await?, + )) +} + +pub async fn update( + State(state): State, + Path(id): Path, + axum::Json(body): axum::Json, +) -> AppResult> { + let sub = sub_repo::get(&state.pool, id).await?; + let domain = crate::repositories::domains::get(&state.pool, sub.domain_id).await?; + let fqdn = format!("{}.{}", body.name, domain.zone_name); + Ok(axum::Json(sub_repo::update(&state.pool, id, &body.name, &fqdn).await?)) +} + +pub async fn delete( + State(state): State, + Path(id): Path, +) -> AppResult> { + sub_repo::delete(&state.pool, id).await?; + Ok(axum::Json(serde_json::json!({ "deleted": true }))) +} diff --git a/backend/src/api/handlers/sync.rs b/backend/src/api/handlers/sync.rs new file mode 100644 index 0000000..b31a592 --- /dev/null +++ b/backend/src/api/handlers/sync.rs @@ -0,0 +1,29 @@ +use crate::error::AppResult; +use crate::services::sync_service; +use crate::state::AppState; +use axum::extract::{Path, State}; + +pub async fn sync_domain( + State(state): State, + Path(id): Path, +) -> AppResult> { + let (job_id, changes) = sync_service::sync_domain(&state.pool, &state.cf, id).await?; + Ok(axum::Json(serde_json::json!({ + "job_id": job_id, + "changes": changes, + }))) +} + +pub async fn sync_all( + State(state): State, +) -> AppResult> { + let job_id = sync_service::sync_all(&state.pool, &state.cf).await?; + Ok(axum::Json(serde_json::json!({ "job_id": job_id }))) +} + +pub async fn get_job( + State(state): State, + Path(id): Path, +) -> AppResult> { + Ok(axum::Json(sync_service::get_job(&state.pool, &id).await?)) +} diff --git a/backend/src/api/mod.rs b/backend/src/api/mod.rs new file mode 100644 index 0000000..a201f1b --- /dev/null +++ b/backend/src/api/mod.rs @@ -0,0 +1,4 @@ +pub mod router; +pub mod handlers; + +pub use router::create_router; diff --git a/backend/src/api/router.rs b/backend/src/api/router.rs new file mode 100644 index 0000000..1881698 --- /dev/null +++ b/backend/src/api/router.rs @@ -0,0 +1,90 @@ +use super::handlers::{auth, certificates, dns, domains, groups, health, services, subdomains, sync}; +use crate::state::AppState; +use axum::{ + middleware, + routing::{get, post, put}, + Router, +}; +use tower_http::cors::{Any, CorsLayer}; +use tower_http::services::{ServeDir, ServeFile}; +use tower_http::trace::TraceLayer; + +pub fn create_router(state: AppState) -> Router { + let static_dir = state + .config + .static_dir + .clone() + .unwrap_or_else(|| std::path::PathBuf::from("./static")); + let index = static_dir.join("index.html"); + let static_service = ServeDir::new(static_dir).not_found_service(ServeFile::new(index)); + + let protected = Router::new() + .route("/groups", get(groups::list).post(groups::create)) + .route( + "/groups/{id}", + get(groups::get_one) + .patch(groups::update) + .delete(groups::delete), + ) + .route("/services", get(services::list).post(services::create)) + .route( + "/services/{id}", + get(services::get_one) + .patch(services::update) + .delete(services::delete), + ) + .route("/domains", get(domains::list).post(domains::create)) + .route( + "/domains/{id}", + get(domains::get_one) + .patch(domains::update) + .delete(domains::delete), + ) + .route("/domains/{id}/import", post(domains::import_zone)) + .route("/domains/{id}/services", put(domains::set_services)) + .route("/domains/{id}/dns", get(dns::list).post(dns::create)) + .route("/domains/{id}/dns/bulk", post(dns::bulk)) + .route( + "/domains/{id}/dns/{record_id}", + get(dns::get_one) + .patch(dns::update) + .delete(dns::delete), + ) + .route("/domains/{id}/dns/{record_id}/resolve", post(dns::resolve)) + .route( + "/domains/{id}/subdomains", + get(subdomains::list).post(subdomains::create), + ) + .route( + "/subdomains/{id}", + get(subdomains::get_one) + .patch(subdomains::update) + .delete(subdomains::delete), + ) + .route("/certificates", get(certificates::list)) + .route("/certificates/check", post(certificates::check_all)) + .route("/certificates/summary", get(certificates::summary)) + .route("/certificates/{id}", get(certificates::get_one)) + .route("/domains/{id}/sync", post(sync::sync_domain)) + .route("/sync", post(sync::sync_all)) + .route("/sync/jobs/{id}", get(sync::get_job)) + .layer(middleware::from_fn_with_state(state.clone(), auth::require_auth)); + + let api = Router::new() + .route("/auth/login", post(auth::login)) + .merge(protected); + + Router::new() + .route("/health", get(health::health)) + .route("/ready", get(health::ready)) + .nest("/api/v1", api) + .fallback_service(static_service) + .layer(TraceLayer::new_for_http()) + .layer( + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any), + ) + .with_state(state) +} diff --git a/backend/src/cloudflare/client.rs b/backend/src/cloudflare/client.rs new file mode 100644 index 0000000..7016075 --- /dev/null +++ b/backend/src/cloudflare/client.rs @@ -0,0 +1,192 @@ +use crate::cloudflare::retry::{parse_retry_after, with_retry}; +use crate::cloudflare::types::{ + CfDnsRecord, CfListResult, CfResponse, CfZone, CreateDnsRecordPayload, +}; +use crate::error::{AppError, AppResult}; +use reqwest::Client; +use std::time::Duration; + +const BASE_URL: &str = "https://api.cloudflare.com/client/v4"; + +#[derive(Clone)] +pub struct CloudflareClient { + http: Client, + token: String, +} + +impl CloudflareClient { + pub fn new(token: impl Into) -> Self { + Self { + http: Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .expect("http client"), + token: token.into(), + } + } + + fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + req.bearer_auth(&self.token) + } + + async fn handle_response( + &self, + response: reqwest::Response, + operation: &str, + ) -> AppResult { + let status = response.status(); + let headers = response.headers().clone(); + + if status.as_u16() == 429 { + let wait = parse_retry_after(&headers).unwrap_or(Duration::from_secs(5)); + tracing::warn!(operation, ?wait, "cloudflare rate limited"); + return Err(AppError::Cloudflare(format!( + "rate limited, retry after {:?}", + wait + ))); + } + + let body: CfResponse = response.json().await.map_err(|e| { + AppError::Cloudflare(format!("{operation}: invalid response: {e}")) + })?; + + if !body.success { + let msg = body + .errors + .map(|errs| { + errs.iter() + .map(|e| e.message.clone()) + .collect::>() + .join("; ") + }) + .unwrap_or_else(|| "unknown cloudflare error".into()); + return Err(AppError::Cloudflare(format!("{operation}: {msg}"))); + } + + body.result + .ok_or_else(|| AppError::Cloudflare(format!("{operation}: empty result"))) + } + + pub async fn list_zones(&self) -> AppResult> { + let token = self.token.clone(); + let http = self.http.clone(); + with_retry(|| { + let http = http.clone(); + let token = token.clone(); + async move { + let response = http + .get(format!("{BASE_URL}/zones")) + .bearer_auth(token) + .query(&[("per_page", "50")]) + .send() + .await + .map_err(|e| AppError::Cloudflare(e.to_string()))?; + + if response.status().is_server_error() || response.status().as_u16() == 429 { + return Err(AppError::Cloudflare(response.status().to_string())); + } + + let list: CfListResult = response.json().await.map_err(|e| { + AppError::Cloudflare(e.to_string()) + })?; + Ok(list.result) + } + }) + .await + } + + pub async fn get_zone(&self, zone_id: &str) -> AppResult { + let url = format!("{BASE_URL}/zones/{zone_id}"); + let response = self + .auth(self.http.get(&url)) + .send() + .await + .map_err(|e| AppError::Cloudflare(e.to_string()))?; + self.handle_response(response, "get_zone").await + } + + pub async fn list_dns_records(&self, zone_id: &str) -> AppResult> { + let zone_id = zone_id.to_string(); + let token = self.token.clone(); + let http = self.http.clone(); + with_retry(|| { + let http = http.clone(); + let token = token.clone(); + let zone_id = zone_id.clone(); + async move { + let mut all = Vec::new(); + let mut page = 1u32; + loop { + let response = http + .get(format!("{BASE_URL}/zones/{zone_id}/dns_records")) + .bearer_auth(&token) + .query(&[("per_page", "100"), ("page", &page.to_string())]) + .send() + .await + .map_err(|e| AppError::Cloudflare(e.to_string()))?; + + if response.status().is_server_error() || response.status().as_u16() == 429 { + return Err(AppError::Cloudflare(response.status().to_string())); + } + + let list: CfListResult = response.json().await.map_err(|e| { + AppError::Cloudflare(e.to_string()) + })?; + if list.result.is_empty() { + break; + } + all.extend(list.result); + page += 1; + if page > 50 { + break; + } + } + Ok(all) + } + }) + .await + } + + pub async fn create_dns_record( + &self, + zone_id: &str, + payload: &CreateDnsRecordPayload, + ) -> AppResult { + let url = format!("{BASE_URL}/zones/{zone_id}/dns_records"); + let response = self + .auth(self.http.post(&url)) + .json(payload) + .send() + .await + .map_err(|e| AppError::Cloudflare(e.to_string()))?; + self.handle_response(response, "create_dns_record").await + } + + pub async fn update_dns_record( + &self, + zone_id: &str, + record_id: &str, + payload: &CreateDnsRecordPayload, + ) -> AppResult { + let url = format!("{BASE_URL}/zones/{zone_id}/dns_records/{record_id}"); + let response = self + .auth(self.http.put(&url)) + .json(payload) + .send() + .await + .map_err(|e| AppError::Cloudflare(e.to_string()))?; + self.handle_response(response, "update_dns_record").await + } + + pub async fn delete_dns_record(&self, zone_id: &str, record_id: &str) -> AppResult<()> { + let url = format!("{BASE_URL}/zones/{zone_id}/dns_records/{record_id}"); + let response = self + .auth(self.http.delete(&url)) + .send() + .await + .map_err(|e| AppError::Cloudflare(e.to_string()))?; + let _: CfResponse = + self.handle_response(response, "delete_dns_record").await?; + Ok(()) + } +} diff --git a/backend/src/cloudflare/mod.rs b/backend/src/cloudflare/mod.rs new file mode 100644 index 0000000..14e058b --- /dev/null +++ b/backend/src/cloudflare/mod.rs @@ -0,0 +1,5 @@ +pub mod client; +pub mod retry; +pub mod types; + +pub use client::CloudflareClient; diff --git a/backend/src/cloudflare/retry.rs b/backend/src/cloudflare/retry.rs new file mode 100644 index 0000000..693fc33 --- /dev/null +++ b/backend/src/cloudflare/retry.rs @@ -0,0 +1,36 @@ +use std::time::Duration; +use tokio::time::sleep; + +pub async fn with_retry(mut operation: F) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, + E: std::fmt::Display, +{ + let mut delay = Duration::from_millis(500); + let mut last_err = None; + + for attempt in 0..3 { + match operation().await { + Ok(value) => return Ok(value), + Err(err) => { + tracing::warn!(attempt = attempt + 1, error = %err, "cloudflare retry"); + last_err = Some(err); + if attempt < 2 { + sleep(delay).await; + delay *= 2; + } + } + } + } + + Err(last_err.expect("retry loop")) +} + +pub fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option { + headers + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()) + .map(Duration::from_secs) +} diff --git a/backend/src/cloudflare/types.rs b/backend/src/cloudflare/types.rs new file mode 100644 index 0000000..8c9c7ca --- /dev/null +++ b/backend/src/cloudflare/types.rs @@ -0,0 +1,49 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CfZone { + pub id: String, + pub name: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CfDnsRecord { + pub id: Option, + #[serde(rename = "type")] + pub record_type: String, + pub name: String, + pub content: String, + pub ttl: i64, + pub proxied: Option, + pub priority: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CreateDnsRecordPayload { + #[serde(rename = "type")] + pub record_type: String, + pub name: String, + pub content: String, + pub ttl: i64, + pub proxied: Option, + pub priority: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CfResponse { + pub success: bool, + pub result: Option, + pub errors: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct CfListResult { + pub result: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct CfApiError { + pub code: i64, + pub message: String, +} diff --git a/backend/src/config.rs b/backend/src/config.rs new file mode 100644 index 0000000..3f12708 --- /dev/null +++ b/backend/src/config.rs @@ -0,0 +1,47 @@ +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub struct Config { + pub database_url: String, + pub cloudflare_api_token: String, + pub jwt_secret: String, + pub jwt_ttl_hours: i64, + pub admin_username: String, + pub admin_password_hash: String, + pub server_port: u16, + pub static_dir: Option, + pub cert_check_cron: String, + pub rust_log: String, +} + +impl Config { + pub fn from_env() -> Result { + Ok(Self { + database_url: std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "sqlite:data/app.db".into()), + cloudflare_api_token: std::env::var("CLOUDFLARE_API_TOKEN") + .unwrap_or_default(), + jwt_secret: std::env::var("JWT_SECRET") + .unwrap_or_else(|_| "dev-secret-change-me".into()), + jwt_ttl_hours: std::env::var("JWT_TTL_HOURS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(24), + admin_username: std::env::var("ADMIN_USERNAME") + .unwrap_or_else(|_| "admin".into()), + admin_password_hash: std::env::var("ADMIN_PASSWORD_HASH") + .unwrap_or_else(|_| { + // default password: admin (for dev only) + "$argon2id$v=19$m=19456,t=2,p=1$devplaceholder$dev".into() + }), + server_port: std::env::var("SERVER_PORT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(8080), + static_dir: std::env::var("STATIC_DIR").ok().map(PathBuf::from), + cert_check_cron: std::env::var("CERT_CHECK_CRON") + .unwrap_or_else(|_| "0 */6 * * *".into()), + rust_log: std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()), + }) + } +} diff --git a/backend/src/domain/entities.rs b/backend/src/domain/entities.rs new file mode 100644 index 0000000..c9ca0f3 --- /dev/null +++ b/backend/src/domain/entities.rs @@ -0,0 +1,95 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Group { + pub id: i64, + pub name: String, + pub slug: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Service { + pub id: i64, + pub name: String, + pub slug: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Domain { + pub id: i64, + pub group_id: Option, + pub zone_name: String, + pub cf_zone_id: String, + pub status: String, + pub last_synced_at: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Subdomain { + pub id: i64, + pub domain_id: i64, + pub name: String, + pub fqdn: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct DnsRecord { + pub id: i64, + pub domain_id: i64, + pub cf_record_id: Option, + pub record_type: String, + pub name: String, + pub content: String, + pub ttl: i64, + pub proxied: bool, + pub priority: Option, + pub sync_status: String, + pub origin: String, + pub last_error: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Certificate { + pub id: i64, + pub domain_id: i64, + pub subdomain_id: Option, + pub hostname: String, + pub expires_at: Option, + pub last_checked_at: Option, + pub last_error: Option, + pub status: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct SyncJob { + pub id: String, + pub status: String, + pub domain_id: Option, + pub message: Option, + pub created_at: String, + pub finished_at: Option, +} + +pub const SYNC_SYNCED: &str = "synced"; +pub const SYNC_PENDING_PUSH: &str = "pending_push"; +pub const SYNC_PENDING_DELETE: &str = "pending_delete"; +pub const SYNC_CONFLICT: &str = "conflict"; +pub const SYNC_ERROR: &str = "error"; + +pub const CERT_OK: &str = "ok"; +pub const CERT_WARNING: &str = "warning"; +pub const CERT_EXPIRED: &str = "expired"; +pub const CERT_ERROR: &str = "error"; +pub const CERT_UNKNOWN: &str = "unknown"; diff --git a/backend/src/domain/mod.rs b/backend/src/domain/mod.rs new file mode 100644 index 0000000..58978e8 --- /dev/null +++ b/backend/src/domain/mod.rs @@ -0,0 +1,5 @@ +pub mod entities; +pub mod validators; + +pub use entities::*; +pub use validators::*; diff --git a/backend/src/domain/validators.rs b/backend/src/domain/validators.rs new file mode 100644 index 0000000..071c229 --- /dev/null +++ b/backend/src/domain/validators.rs @@ -0,0 +1,17 @@ +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_a_record() { + assert!(validate_dns_record("A", "@", "192.168.1.1", 1, false).is_ok()); + assert!(validate_dns_record("A", "@", "invalid", 1, false).is_err()); + } + + #[test] + fn cert_status_thresholds() { + assert_eq!(cert_status_from_expiry(60), crate::domain::CERT_OK); + assert_eq!(cert_status_from_expiry(10), crate::domain::CERT_WARNING); + assert_eq!(cert_status_from_expiry(-1), crate::domain::CERT_EXPIRED); + } +} diff --git a/backend/src/error.rs b/backend/src/error.rs new file mode 100644 index 0000000..397d212 --- /dev/null +++ b/backend/src/error.rs @@ -0,0 +1,77 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AppError { + #[error("not found: {0}")] + NotFound(String), + #[error("validation error: {0}")] + Validation(String), + #[error("unauthorized")] + Unauthorized, + #[error("forbidden")] + Forbidden, + #[error("conflict: {0}")] + Conflict(String), + #[error("cloudflare error: {0}")] + Cloudflare(String), + #[error("internal error: {0}")] + Internal(String), +} + +impl AppError { + pub fn code(&self) -> &'static str { + match self { + Self::NotFound(_) => "NOT_FOUND", + Self::Validation(_) => "VALIDATION_ERROR", + Self::Unauthorized => "UNAUTHORIZED", + Self::Forbidden => "FORBIDDEN", + Self::Conflict(_) => "CONFLICT", + Self::Cloudflare(_) => "CLOUDFLARE_ERROR", + Self::Internal(_) => "INTERNAL_ERROR", + } + } + + pub fn status(&self) -> StatusCode { + match self { + Self::NotFound(_) => StatusCode::NOT_FOUND, + Self::Validation(_) => StatusCode::BAD_REQUEST, + Self::Unauthorized => StatusCode::UNAUTHORIZED, + Self::Forbidden => StatusCode::FORBIDDEN, + Self::Conflict(_) => StatusCode::CONFLICT, + Self::Cloudflare(_) => StatusCode::BAD_GATEWAY, + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let body = Json(json!({ + "error": { + "code": self.code(), + "message": self.to_string(), + } + })); + (self.status(), body).into_response() + } +} + +impl From for AppError { + fn from(value: sqlx::Error) -> Self { + Self::Internal(value.to_string()) + } +} + +impl From for AppError { + fn from(value: serde_json::Error) -> Self { + Self::Internal(value.to_string()) + } +} + +pub type AppResult = Result; diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..de86d9d --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,60 @@ +mod api; +mod cloudflare; +mod config; +mod domain; +mod error; +mod repositories; +mod services; +mod state; + +use api::create_router; +use config::Config; +use repositories::{create_pool, run_migrations}; +use state::AppState; +use std::net::SocketAddr; +use tokio_cron_scheduler::{Job, JobScheduler}; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> Result<(), Box> { + dotenvy::dotenv().ok(); + let config = Config::from_env().map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::new(&config.rust_log)) + .init(); + + let pool = create_pool(&config.database_url).await?; + run_migrations(&pool).await?; + + let state = AppState::new(pool.clone(), config.clone()); + start_cert_scheduler(pool, config.cert_check_cron.clone()).await?; + + let app = create_router(state); + let addr = SocketAddr::from(([0, 0, 0, 0], config.server_port)); + tracing::info!("listening on {addr}"); + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + Ok(()) +} + +async fn start_cert_scheduler( + pool: sqlx::SqlitePool, + cron: String, +) -> Result<(), Box> { + let sched = JobScheduler::new().await?; + let pool_clone = pool.clone(); + let job = Job::new_async(cron.as_str(), move |_uuid, _l| { + let pool = pool_clone.clone(); + Box::pin(async move { + tracing::info!("certificate check started"); + match services::certificate_service::run_all_checks(&pool).await { + Ok(n) => tracing::info!(checked = n, "certificate check completed"), + Err(e) => tracing::warn!(error = %e, "certificate check failed"), + } + }) + })?; + sched.add(job).await?; + sched.start().await?; + Ok(()) +} diff --git a/backend/src/repositories/certificates.rs b/backend/src/repositories/certificates.rs new file mode 100644 index 0000000..9c79f38 --- /dev/null +++ b/backend/src/repositories/certificates.rs @@ -0,0 +1,85 @@ +use crate::domain::Certificate; +use crate::error::{AppError, AppResult}; +use sqlx::SqlitePool; + +pub async fn list(pool: &SqlitePool, status: Option<&str>) -> AppResult> { + if let Some(s) = status { + Ok(sqlx::query_as::<_, Certificate>( + "SELECT * FROM certificates WHERE status = ? ORDER BY expires_at", + ) + .bind(s) + .fetch_all(pool) + .await?) + } else { + Ok(sqlx::query_as::<_, Certificate>( + "SELECT * FROM certificates ORDER BY expires_at", + ) + .fetch_all(pool) + .await?) + } +} + +pub async fn get(pool: &SqlitePool, id: i64) -> AppResult { + sqlx::query_as::<_, Certificate>("SELECT * FROM certificates WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("certificate {id}"))) +} + +pub async fn upsert_check( + pool: &SqlitePool, + domain_id: i64, + subdomain_id: Option, + hostname: &str, + expires_at: Option<&str>, + status: &str, + last_error: Option<&str>, +) -> AppResult { + let existing: Option = sqlx::query_scalar( + "SELECT id FROM certificates WHERE hostname = ?", + ) + .bind(hostname) + .fetch_optional(pool) + .await?; + + if let Some(id) = existing { + sqlx::query( + r#"UPDATE certificates SET domain_id = ?, subdomain_id = ?, expires_at = ?, + last_checked_at = datetime('now'), last_error = ?, status = ?, updated_at = datetime('now') + WHERE id = ?"#, + ) + .bind(domain_id) + .bind(subdomain_id) + .bind(expires_at) + .bind(last_error) + .bind(status) + .bind(id) + .execute(pool) + .await?; + return get(pool, id).await; + } + + let id = sqlx::query_scalar::<_, i64>( + r#"INSERT INTO certificates (domain_id, subdomain_id, hostname, expires_at, last_checked_at, last_error, status) + VALUES (?, ?, ?, ?, datetime('now'), ?, ?) RETURNING id"#, + ) + .bind(domain_id) + .bind(subdomain_id) + .bind(hostname) + .bind(expires_at) + .bind(last_error) + .bind(status) + .fetch_one(pool) + .await?; + get(pool, id).await +} + +pub async fn count_by_status(pool: &SqlitePool) -> AppResult> { + let rows = sqlx::query_as::<_, (String, i64)>( + "SELECT status, COUNT(*) FROM certificates GROUP BY status", + ) + .fetch_all(pool) + .await?; + Ok(rows) +} diff --git a/backend/src/repositories/dns_records.rs b/backend/src/repositories/dns_records.rs new file mode 100644 index 0000000..0b6289a --- /dev/null +++ b/backend/src/repositories/dns_records.rs @@ -0,0 +1,203 @@ +use crate::domain::{DnsRecord, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED}; +use crate::error::{AppError, AppResult}; +use sqlx::SqlitePool; + +#[derive(Debug, Clone, Default)] +pub struct DnsListFilter { + pub record_type: Option, + pub name: Option, + pub content: Option, + pub proxied: Option, + pub sync_status: Option, + pub q: Option, + pub sort: String, + pub page: i64, + pub limit: i64, +} + +pub async fn list(pool: &SqlitePool, domain_id: i64, filter: &DnsListFilter) -> AppResult> { + let mut sql = String::from( + "SELECT * FROM dns_records WHERE domain_id = ?", + ); + let mut binds: Vec = Vec::new(); + + if let Some(t) = &filter.record_type { + sql.push_str(" AND record_type = ?"); + binds.push(t.to_uppercase()); + } + if let Some(n) = &filter.name { + sql.push_str(" AND name LIKE ?"); + binds.push(format!("%{n}%")); + } + if let Some(c) = &filter.content { + sql.push_str(" AND content LIKE ?"); + binds.push(format!("%{c}%")); + } + if let Some(p) = filter.proxied { + sql.push_str(" AND proxied = ?"); + binds.push(if p { "1".into() } else { "0".into() }); + } + if let Some(s) = &filter.sync_status { + sql.push_str(" AND sync_status = ?"); + binds.push(s.clone()); + } + if let Some(q) = &filter.q { + sql.push_str(" AND (name LIKE ? OR content LIKE ? OR record_type LIKE ?)"); + let pat = format!("%{q}%"); + binds.push(pat.clone()); + binds.push(pat.clone()); + binds.push(pat); + } + + let order = match filter.sort.as_str() { + "type" => "record_type", + "updated_at" => "updated_at", + _ => "name", + }; + sql.push_str(&format!(" ORDER BY {order} ASC LIMIT ? OFFSET ?")); + + let offset = (filter.page.max(1) - 1) * filter.limit.max(1); + let limit = filter.limit.max(1).min(200); + + let mut query = sqlx::query_as::<_, DnsRecord>(&sql).bind(domain_id); + for b in &binds { + query = query.bind(b); + } + query = query.bind(limit).bind(offset); + Ok(query.fetch_all(pool).await?) +} + +pub async fn get(pool: &SqlitePool, domain_id: i64, id: i64) -> AppResult { + sqlx::query_as::<_, DnsRecord>( + "SELECT * FROM dns_records WHERE id = ? AND domain_id = ?", + ) + .bind(id) + .bind(domain_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("dns record {id}"))) +} + +pub async fn insert( + pool: &SqlitePool, + domain_id: i64, + record_type: &str, + name: &str, + content: &str, + ttl: i64, + proxied: bool, + priority: Option, + sync_status: &str, + origin: &str, + cf_record_id: Option<&str>, +) -> AppResult { + let id = sqlx::query_scalar::<_, i64>( + r#"INSERT INTO dns_records + (domain_id, cf_record_id, record_type, name, content, ttl, proxied, priority, sync_status, origin) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id"#, + ) + .bind(domain_id) + .bind(cf_record_id) + .bind(record_type.to_uppercase()) + .bind(name) + .bind(content) + .bind(ttl) + .bind(proxied) + .bind(priority) + .bind(sync_status) + .bind(origin) + .fetch_one(pool) + .await?; + get(pool, domain_id, id).await +} + +pub async fn update_fields( + pool: &SqlitePool, + id: i64, + record_type: &str, + name: &str, + content: &str, + ttl: i64, + proxied: bool, + priority: Option, + sync_status: &str, + cf_record_id: Option<&str>, + last_error: Option<&str>, +) -> AppResult<()> { + sqlx::query( + r#"UPDATE dns_records SET + cf_record_id = ?, record_type = ?, name = ?, content = ?, ttl = ?, proxied = ?, + priority = ?, sync_status = ?, last_error = ?, updated_at = datetime('now') + WHERE id = ?"#, + ) + .bind(cf_record_id) + .bind(record_type.to_uppercase()) + .bind(name) + .bind(content) + .bind(ttl) + .bind(proxied) + .bind(priority) + .bind(sync_status) + .bind(last_error) + .bind(id) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn set_sync_status( + pool: &SqlitePool, + id: i64, + sync_status: &str, + cf_record_id: Option<&str>, + last_error: Option<&str>, +) -> AppResult<()> { + sqlx::query( + "UPDATE dns_records SET sync_status = ?, cf_record_id = ?, last_error = ?, updated_at = datetime('now') WHERE id = ?", + ) + .bind(sync_status) + .bind(cf_record_id) + .bind(last_error) + .bind(id) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn delete(pool: &SqlitePool, id: i64) -> AppResult<()> { + sqlx::query("DELETE FROM dns_records WHERE id = ?") + .bind(id) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn list_by_domain(pool: &SqlitePool, domain_id: i64) -> AppResult> { + Ok(sqlx::query_as::<_, DnsRecord>( + "SELECT * FROM dns_records WHERE domain_id = ?", + ) + .bind(domain_id) + .fetch_all(pool) + .await?) +} + +pub async fn find_by_cf_id( + pool: &SqlitePool, + domain_id: i64, + cf_record_id: &str, +) -> AppResult> { + Ok(sqlx::query_as::<_, DnsRecord>( + "SELECT * FROM dns_records WHERE domain_id = ? AND cf_record_id = ?", + ) + .bind(domain_id) + .bind(cf_record_id) + .fetch_optional(pool) + .await?) +} + +pub async fn mark_pending_delete(pool: &SqlitePool, id: i64) -> AppResult<()> { + set_sync_status(pool, id, SYNC_PENDING_DELETE, None, None).await +} + +pub const SYNC_PUSH: &str = SYNC_PENDING_PUSH; +pub const SYNC_DONE: &str = SYNC_SYNCED; diff --git a/backend/src/repositories/domains.rs b/backend/src/repositories/domains.rs new file mode 100644 index 0000000..62be7da --- /dev/null +++ b/backend/src/repositories/domains.rs @@ -0,0 +1,90 @@ +use crate::domain::Domain; +use crate::error::{AppError, AppResult}; +use sqlx::SqlitePool; + +pub async fn list(pool: &SqlitePool, group_id: Option) -> AppResult> { + if let Some(gid) = group_id { + Ok(sqlx::query_as::<_, Domain>( + "SELECT * FROM domains WHERE group_id = ? ORDER BY zone_name", + ) + .bind(gid) + .fetch_all(pool) + .await?) + } else { + Ok(sqlx::query_as::<_, Domain>("SELECT * FROM domains ORDER BY zone_name") + .fetch_all(pool) + .await?) + } +} + +pub async fn get(pool: &SqlitePool, id: i64) -> AppResult { + sqlx::query_as::<_, Domain>("SELECT * FROM domains WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("domain {id}"))) +} + +pub async fn create( + pool: &SqlitePool, + group_id: Option, + zone_name: &str, + cf_zone_id: &str, +) -> AppResult { + let id = sqlx::query_scalar::<_, i64>( + "INSERT INTO domains (group_id, zone_name, cf_zone_id) VALUES (?, ?, ?) RETURNING id", + ) + .bind(group_id) + .bind(zone_name) + .bind(cf_zone_id) + .fetch_one(pool) + .await?; + get(pool, id).await +} + +pub async fn update( + pool: &SqlitePool, + id: i64, + group_id: Option, + status: &str, +) -> AppResult { + let affected = sqlx::query( + "UPDATE domains SET group_id = ?, status = ?, updated_at = datetime('now') WHERE id = ?", + ) + .bind(group_id) + .bind(status) + .bind(id) + .execute(pool) + .await? + .rows_affected(); + if affected == 0 { + return Err(AppError::NotFound(format!("domain {id}"))); + } + get(pool, id).await +} + +pub async fn delete(pool: &SqlitePool, id: i64) -> AppResult<()> { + let affected = sqlx::query("DELETE FROM domains WHERE id = ?") + .bind(id) + .execute(pool) + .await? + .rows_affected(); + if affected == 0 { + return Err(AppError::NotFound(format!("domain {id}"))); + } + Ok(()) +} + +pub async fn set_last_synced(pool: &SqlitePool, id: i64) -> AppResult<()> { + sqlx::query( + "UPDATE domains SET last_synced_at = datetime('now'), updated_at = datetime('now') WHERE id = ?", + ) + .bind(id) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn list_all(pool: &SqlitePool) -> AppResult> { + list(pool, None).await +} diff --git a/backend/src/repositories/groups.rs b/backend/src/repositories/groups.rs new file mode 100644 index 0000000..6a98dc9 --- /dev/null +++ b/backend/src/repositories/groups.rs @@ -0,0 +1,57 @@ +use crate::domain::Group; +use crate::error::{AppError, AppResult}; +use sqlx::SqlitePool; + +pub async fn list(pool: &SqlitePool) -> AppResult> { + let rows = sqlx::query_as::<_, Group>("SELECT * FROM groups ORDER BY name") + .fetch_all(pool) + .await?; + Ok(rows) +} + +pub async fn get(pool: &SqlitePool, id: i64) -> AppResult { + sqlx::query_as::<_, Group>("SELECT * FROM groups WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("group {id}"))) +} + +pub async fn create(pool: &SqlitePool, name: &str, slug: &str) -> AppResult { + let id = sqlx::query_scalar::<_, i64>( + "INSERT INTO groups (name, slug) VALUES (?, ?) RETURNING id", + ) + .bind(name) + .bind(slug) + .fetch_one(pool) + .await?; + get(pool, id).await +} + +pub async fn update(pool: &SqlitePool, id: i64, name: &str, slug: &str) -> AppResult { + let affected = sqlx::query( + "UPDATE groups SET name = ?, slug = ?, updated_at = datetime('now') WHERE id = ?", + ) + .bind(name) + .bind(slug) + .bind(id) + .execute(pool) + .await? + .rows_affected(); + if affected == 0 { + return Err(AppError::NotFound(format!("group {id}"))); + } + get(pool, id).await +} + +pub async fn delete(pool: &SqlitePool, id: i64) -> AppResult<()> { + let affected = sqlx::query("DELETE FROM groups WHERE id = ?") + .bind(id) + .execute(pool) + .await? + .rows_affected(); + if affected == 0 { + return Err(AppError::NotFound(format!("group {id}"))); + } + Ok(()) +} diff --git a/backend/src/repositories/mod.rs b/backend/src/repositories/mod.rs new file mode 100644 index 0000000..60b7db2 --- /dev/null +++ b/backend/src/repositories/mod.rs @@ -0,0 +1,31 @@ +pub mod certificates; +pub mod dns_records; +pub mod domains; +pub mod groups; +pub mod services; +pub mod subdomains; +pub mod sync_jobs; + +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use sqlx::SqlitePool; +use std::str::FromStr; +use std::time::Duration; + +pub async fn create_pool(database_url: &str) -> Result { + let url = database_url.strip_prefix("sqlite:").unwrap_or(database_url); + let options = SqliteConnectOptions::from_str(url)? + .create_if_missing(true) + .foreign_keys(true) + .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) + .synchronous(sqlx::sqlite::SqliteSynchronous::Normal); + + SqlitePoolOptions::new() + .max_connections(5) + .acquire_timeout(Duration::from_secs(10)) + .connect_with(options) + .await +} + +pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> { + sqlx::migrate!("./migrations").run(pool).await +} diff --git a/backend/src/repositories/services.rs b/backend/src/repositories/services.rs new file mode 100644 index 0000000..89be800 --- /dev/null +++ b/backend/src/repositories/services.rs @@ -0,0 +1,86 @@ +use crate::domain::Service; +use crate::error::{AppError, AppResult}; +use sqlx::SqlitePool; + +pub async fn list(pool: &SqlitePool) -> AppResult> { + Ok(sqlx::query_as::<_, Service>("SELECT * FROM services ORDER BY name") + .fetch_all(pool) + .await?) +} + +pub async fn get(pool: &SqlitePool, id: i64) -> AppResult { + sqlx::query_as::<_, Service>("SELECT * FROM services WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("service {id}"))) +} + +pub async fn create(pool: &SqlitePool, name: &str, slug: &str) -> AppResult { + let id = sqlx::query_scalar::<_, i64>( + "INSERT INTO services (name, slug) VALUES (?, ?) RETURNING id", + ) + .bind(name) + .bind(slug) + .fetch_one(pool) + .await?; + get(pool, id).await +} + +pub async fn update(pool: &SqlitePool, id: i64, name: &str, slug: &str) -> AppResult { + let affected = sqlx::query( + "UPDATE services SET name = ?, slug = ?, updated_at = datetime('now') WHERE id = ?", + ) + .bind(name) + .bind(slug) + .bind(id) + .execute(pool) + .await? + .rows_affected(); + if affected == 0 { + return Err(AppError::NotFound(format!("service {id}"))); + } + get(pool, id).await +} + +pub async fn delete(pool: &SqlitePool, id: i64) -> AppResult<()> { + let affected = sqlx::query("DELETE FROM services WHERE id = ?") + .bind(id) + .execute(pool) + .await? + .rows_affected(); + if affected == 0 { + return Err(AppError::NotFound(format!("service {id}"))); + } + Ok(()) +} + +pub async fn set_domain_services( + pool: &SqlitePool, + domain_id: i64, + service_ids: &[i64], +) -> AppResult<()> { + let mut tx = pool.begin().await?; + sqlx::query("DELETE FROM domain_services WHERE domain_id = ?") + .bind(domain_id) + .execute(&mut *tx) + .await?; + for sid in service_ids { + sqlx::query("INSERT INTO domain_services (domain_id, service_id) VALUES (?, ?)") + .bind(domain_id) + .bind(sid) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(()) +} + +pub async fn list_domain_service_ids(pool: &SqlitePool, domain_id: i64) -> AppResult> { + Ok(sqlx::query_scalar::<_, i64>( + "SELECT service_id FROM domain_services WHERE domain_id = ?", + ) + .bind(domain_id) + .fetch_all(pool) + .await?) +} diff --git a/backend/src/repositories/subdomains.rs b/backend/src/repositories/subdomains.rs new file mode 100644 index 0000000..2f163e8 --- /dev/null +++ b/backend/src/repositories/subdomains.rs @@ -0,0 +1,71 @@ +use crate::domain::Subdomain; +use crate::error::{AppError, AppResult}; +use sqlx::SqlitePool; + +pub async fn list_by_domain(pool: &SqlitePool, domain_id: i64) -> AppResult> { + Ok(sqlx::query_as::<_, Subdomain>( + "SELECT * FROM subdomains WHERE domain_id = ? ORDER BY name", + ) + .bind(domain_id) + .fetch_all(pool) + .await?) +} + +pub async fn get(pool: &SqlitePool, id: i64) -> AppResult { + sqlx::query_as::<_, Subdomain>("SELECT * FROM subdomains WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("subdomain {id}"))) +} + +pub async fn create( + pool: &SqlitePool, + domain_id: i64, + name: &str, + fqdn: &str, +) -> AppResult { + let id = sqlx::query_scalar::<_, i64>( + "INSERT INTO subdomains (domain_id, name, fqdn) VALUES (?, ?, ?) RETURNING id", + ) + .bind(domain_id) + .bind(name) + .bind(fqdn) + .fetch_one(pool) + .await?; + get(pool, id).await +} + +pub async fn update(pool: &SqlitePool, id: i64, name: &str, fqdn: &str) -> AppResult { + let affected = sqlx::query( + "UPDATE subdomains SET name = ?, fqdn = ?, updated_at = datetime('now') WHERE id = ?", + ) + .bind(name) + .bind(fqdn) + .bind(id) + .execute(pool) + .await? + .rows_affected(); + if affected == 0 { + return Err(AppError::NotFound(format!("subdomain {id}"))); + } + get(pool, id).await +} + +pub async fn delete(pool: &SqlitePool, id: i64) -> AppResult<()> { + let affected = sqlx::query("DELETE FROM subdomains WHERE id = ?") + .bind(id) + .execute(pool) + .await? + .rows_affected(); + if affected == 0 { + return Err(AppError::NotFound(format!("subdomain {id}"))); + } + Ok(()) +} + +pub async fn list_all(pool: &SqlitePool) -> AppResult> { + Ok(sqlx::query_as::<_, Subdomain>("SELECT * FROM subdomains ORDER BY fqdn") + .fetch_all(pool) + .await?) +} diff --git a/backend/src/repositories/sync_jobs.rs b/backend/src/repositories/sync_jobs.rs new file mode 100644 index 0000000..824cbd9 --- /dev/null +++ b/backend/src/repositories/sync_jobs.rs @@ -0,0 +1,43 @@ +use crate::domain::SyncJob; +use crate::error::{AppError, AppResult}; +use sqlx::SqlitePool; + +pub async fn create( + pool: &SqlitePool, + id: &str, + domain_id: Option, +) -> AppResult { + sqlx::query( + "INSERT INTO sync_jobs (id, domain_id, status) VALUES (?, ?, 'pending')", + ) + .bind(id) + .bind(domain_id) + .execute(pool) + .await?; + get(pool, id).await +} + +pub async fn get(pool: &SqlitePool, id: &str) -> AppResult { + sqlx::query_as::<_, SyncJob>("SELECT * FROM sync_jobs WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("sync job {id}"))) +} + +pub async fn finish( + pool: &SqlitePool, + id: &str, + status: &str, + message: Option<&str>, +) -> AppResult<()> { + sqlx::query( + "UPDATE sync_jobs SET status = ?, message = ?, finished_at = datetime('now') WHERE id = ?", + ) + .bind(status) + .bind(message) + .bind(id) + .execute(pool) + .await?; + Ok(()) +} diff --git a/backend/src/services/auth.rs b/backend/src/services/auth.rs new file mode 100644 index 0000000..accbbb6 --- /dev/null +++ b/backend/src/services/auth.rs @@ -0,0 +1,71 @@ +use crate::config::Config; +use crate::error::{AppError, AppResult}; +use argon2::{password_hash::PasswordHash, Argon2, PasswordVerifier}; +use chrono::{Duration, Utc}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub sub: String, + pub exp: i64, +} + +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub username: String, + pub password: String, +} + +#[derive(Debug, Serialize)] +pub struct LoginResponse { + pub token: String, + pub expires_at: String, +} + +pub fn verify_password(config: &Config, password: &str) -> AppResult<()> { + if config.admin_password_hash.contains("devplaceholder") { + if password == "admin" { + return Ok(()); + } + return Err(AppError::Unauthorized); + } + let parsed = PasswordHash::new(&config.admin_password_hash) + .map_err(|e| AppError::Internal(e.to_string()))?; + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .map_err(|_| AppError::Unauthorized)?; + Ok(()) +} + +pub fn login(config: &Config, req: LoginRequest) -> AppResult { + if req.username != config.admin_username { + return Err(AppError::Unauthorized); + } + verify_password(config, &req.password)?; + let exp = Utc::now() + Duration::hours(config.jwt_ttl_hours); + let claims = Claims { + sub: req.username.clone(), + exp: exp.timestamp(), + }; + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(config.jwt_secret.as_bytes()), + ) + .map_err(|e| AppError::Internal(e.to_string()))?; + Ok(LoginResponse { + token, + expires_at: exp.to_rfc3339(), + }) +} + +pub fn validate_token(config: &Config, token: &str) -> AppResult { + decode::( + token, + &DecodingKey::from_secret(config.jwt_secret.as_bytes()), + &Validation::default(), + ) + .map(|d| d.claims) + .map_err(|_| AppError::Unauthorized) +} diff --git a/backend/src/services/certificate_service.rs b/backend/src/services/certificate_service.rs new file mode 100644 index 0000000..5c237e6 --- /dev/null +++ b/backend/src/services/certificate_service.rs @@ -0,0 +1,138 @@ +use crate::domain::{cert_status_from_expiry, Certificate, CERT_ERROR, CERT_UNKNOWN}; +use crate::error::AppResult; +use crate::repositories::{certificates, domains, subdomains}; +use chrono::Utc; +use sqlx::SqlitePool; +use rustls::{ClientConfig, RootCertStore}; +use rustls::pki_types::ServerName; +use std::net::ToSocketAddrs; +use std::sync::Arc; +use tokio::net::TcpStream; +use tokio::time::{timeout, Duration as TokioDuration}; +use tokio_rustls::TlsConnector; +use x509_parser::prelude::FromDer; + +pub async fn list_certificates(pool: &SqlitePool, status: Option<&str>) -> AppResult> { + certificates::list(pool, status).await +} + +pub async fn get_certificate(pool: &SqlitePool, id: i64) -> AppResult { + certificates::get(pool, id).await +} + +pub async fn check_hostname(hostname: &str) -> (Option>, Option) { + let addr = match format!("{hostname}:443").to_socket_addrs() { + Ok(mut addrs) => match addrs.next() { + Some(a) => a, + None => return (None, Some("cannot resolve host".into())), + }, + Err(e) => return (None, Some(e.to_string())), + }; + + let stream = match timeout(TokioDuration::from_secs(10), TcpStream::connect(addr)).await { + Ok(Ok(s)) => s, + Ok(Err(e)) => return (None, Some(e.to_string())), + Err(_) => return (None, Some("connection timeout".into())), + }; + + let mut root_store = RootCertStore::empty(); + root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + + let config = ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(); + + let connector = TlsConnector::from(Arc::new(config)); + let server_name = match ServerName::try_from(hostname.to_string()) { + Ok(n) => n, + Err(e) => return (None, Some(e.to_string())), + }; + + let tls = match connector.connect(server_name, stream).await { + Ok(s) => s, + Err(e) => return (None, Some(e.to_string())), + }; + + let (_, session) = tls.into_inner(); + let certs = session.peer_certificates(); + let Some(chain) = certs else { + return (None, Some("no peer certificates".into())); + }; + let Some(leaf) = chain.first() else { + return (None, Some("empty cert chain".into())); + }; + + match x509_parser::certificate::X509Certificate::from_der(leaf.as_ref()) { + Ok((_, cert)) => { + let not_after = cert.validity().not_after.timestamp(); + let expires = chrono::DateTime::from_timestamp(not_after, 0); + (expires, None) + } + Err(e) => (None, Some(e.to_string())), + } +} + +pub async fn check_and_store( + pool: &SqlitePool, + domain_id: i64, + subdomain_id: Option, + hostname: &str, +) -> AppResult { + let (expires_at, err) = check_hostname(hostname).await; + let status = if let Some(err_msg) = &err { + certificates::upsert_check( + pool, + domain_id, + subdomain_id, + hostname, + expires_at.map(|e| e.to_rfc3339()).as_deref(), + CERT_ERROR, + Some(err_msg), + ) + .await? + } else if let Some(exp) = expires_at { + let days = (exp - Utc::now()).num_days(); + let st = cert_status_from_expiry(days); + certificates::upsert_check( + pool, + domain_id, + subdomain_id, + hostname, + Some(&exp.to_rfc3339()), + st, + None, + ) + .await? + } else { + certificates::upsert_check( + pool, + domain_id, + subdomain_id, + hostname, + None, + CERT_UNKNOWN, + Some("unknown expiry"), + ) + .await? + }; + Ok(status) +} + +pub async fn run_all_checks(pool: &SqlitePool) -> AppResult { + let mut count = 0usize; + let all_domains = domains::list_all(pool).await?; + for domain in all_domains { + check_and_store(pool, domain.id, None, &domain.zone_name).await?; + count += 1; + } + let subs = subdomains::list_all(pool).await?; + for sub in subs { + check_and_store(pool, sub.domain_id, Some(sub.id), &sub.fqdn).await?; + count += 1; + } + Ok(count) +} + +pub async fn status_summary(pool: &SqlitePool) -> AppResult> { + certificates::count_by_status(pool).await +} diff --git a/backend/src/services/dns_service.rs b/backend/src/services/dns_service.rs new file mode 100644 index 0000000..0ef1d52 --- /dev/null +++ b/backend/src/services/dns_service.rs @@ -0,0 +1,320 @@ +use crate::cloudflare::types::CreateDnsRecordPayload; +use crate::cloudflare::CloudflareClient; +use crate::domain::{DnsRecord, Domain, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED}; +use crate::domain::validate_dns_record; +use crate::error::{AppError, AppResult}; +use crate::repositories::{dns_records, domains}; +use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; + +#[derive(Debug, Deserialize)] +pub struct CreateDnsRequest { + pub record_type: String, + pub name: String, + pub content: String, + pub ttl: Option, + pub proxied: Option, + pub priority: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateDnsRequest { + pub record_type: Option, + pub name: Option, + pub content: Option, + pub ttl: Option, + pub proxied: Option, + pub priority: Option, +} + +#[derive(Debug, Deserialize)] +pub struct BulkDnsOp { + pub action: String, + pub id: Option, + pub record: Option, +} + +#[derive(Debug, Serialize)] +pub struct BulkDnsResult { + pub id: Option, + pub success: bool, + pub error: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ResolveDnsRequest { + pub source: String, +} + +fn to_cf_payload( + record_type: &str, + name: &str, + content: &str, + ttl: i64, + proxied: bool, + priority: Option, +) -> CreateDnsRecordPayload { + CreateDnsRecordPayload { + record_type: record_type.to_uppercase(), + name: name.to_string(), + content: content.to_string(), + ttl, + proxied: Some(proxied), + priority, + } +} + +async fn push_record( + pool: &SqlitePool, + cf: &CloudflareClient, + domain: &Domain, + record: &DnsRecord, +) -> AppResult { + let payload = to_cf_payload( + &record.record_type, + &record.name, + &record.content, + record.ttl, + record.proxied, + record.priority, + ); + + let result = if let Some(cf_id) = &record.cf_record_id { + cf.update_dns_record(&domain.cf_zone_id, cf_id, &payload).await + } else { + cf.create_dns_record(&domain.cf_zone_id, &payload).await + }; + + match result { + Ok(cf_rec) => { + let cf_id = cf_rec.id.as_deref(); + dns_records::update_fields( + pool, + record.id, + &record.record_type, + &record.name, + &record.content, + record.ttl, + record.proxied, + record.priority, + SYNC_SYNCED, + cf_id, + None, + ) + .await?; + dns_records::get(pool, domain.id, record.id).await + } + Err(e) => { + dns_records::set_sync_status(pool, record.id, SYNC_ERROR, record.cf_record_id.as_deref(), Some(&e.to_string())).await?; + Err(e) + } + } +} + +pub async fn create( + pool: &SqlitePool, + cf: &CloudflareClient, + domain_id: i64, + req: CreateDnsRequest, +) -> AppResult { + let domain = domains::get(pool, domain_id).await?; + let ttl = req.ttl.unwrap_or(1); + let proxied = req.proxied.unwrap_or(false); + validate_dns_record(&req.record_type, &req.name, &req.content, ttl, proxied)?; + + let record = dns_records::insert( + pool, + domain_id, + &req.record_type, + &req.name, + &req.content, + ttl, + proxied, + req.priority, + SYNC_PENDING_PUSH, + "local", + None, + ) + .await?; + + push_record(pool, cf, &domain, &record).await +} + +pub async fn update( + pool: &SqlitePool, + cf: &CloudflareClient, + domain_id: i64, + record_id: i64, + req: UpdateDnsRequest, +) -> AppResult { + let domain = domains::get(pool, domain_id).await?; + let existing = dns_records::get(pool, domain_id, record_id).await?; + + let record_type = req.record_type.unwrap_or(existing.record_type); + let name = req.name.unwrap_or(existing.name); + let content = req.content.unwrap_or(existing.content); + let ttl = req.ttl.unwrap_or(existing.ttl); + let proxied = req.proxied.unwrap_or(existing.proxied); + let priority = req.priority.or(existing.priority); + + validate_dns_record(&record_type, &name, &content, ttl, proxied)?; + dns_records::update_fields( + pool, + record_id, + &record_type, + &name, + &content, + ttl, + proxied, + priority, + SYNC_PENDING_PUSH, + existing.cf_record_id.as_deref(), + None, + ) + .await?; + + let updated = dns_records::get(pool, domain_id, record_id).await?; + push_record(pool, cf, &domain, &updated).await +} + +pub async fn delete_record( + pool: &SqlitePool, + cf: &CloudflareClient, + domain_id: i64, + record_id: i64, +) -> AppResult<()> { + let domain = domains::get(pool, domain_id).await?; + let record = dns_records::get(pool, domain_id, record_id).await?; + dns_records::mark_pending_delete(pool, record_id).await?; + + if let Some(cf_id) = &record.cf_record_id { + if let Err(e) = cf.delete_dns_record(&domain.cf_zone_id, cf_id).await { + dns_records::set_sync_status(pool, record_id, SYNC_ERROR, Some(cf_id), Some(&e.to_string())).await?; + return Err(e); + } + } + dns_records::delete(pool, record_id).await +} + +pub async fn list( + pool: &SqlitePool, + domain_id: i64, + filter: dns_records::DnsListFilter, +) -> AppResult> { + domains::get(pool, domain_id).await?; + dns_records::list(pool, domain_id, &filter).await +} + +pub async fn get(pool: &SqlitePool, domain_id: i64, record_id: i64) -> AppResult { + dns_records::get(pool, domain_id, record_id).await +} + +pub async fn bulk( + pool: &SqlitePool, + cf: &CloudflareClient, + domain_id: i64, + ops: Vec, +) -> AppResult> { + let mut results = Vec::new(); + for op in ops { + let res = match op.action.as_str() { + "create" => { + let record = op.record.ok_or_else(|| AppError::Validation("record required".into()))?; + create(pool, cf, domain_id, record) + .await + .map(|r| BulkDnsResult { id: Some(r.id), success: true, error: None }) + .unwrap_or_else(|e| BulkDnsResult { + id: None, + success: false, + error: Some(e.to_string()), + }) + } + "update" => { + let id = op.id.ok_or_else(|| AppError::Validation("id required".into()))?; + let record = op.record.ok_or_else(|| AppError::Validation("record required".into()))?; + let update_req = UpdateDnsRequest { + record_type: Some(record.record_type), + name: Some(record.name), + content: Some(record.content), + ttl: record.ttl, + proxied: record.proxied, + priority: record.priority, + }; + update(pool, cf, domain_id, id, update_req) + .await + .map(|_| BulkDnsResult { id: Some(id), success: true, error: None }) + .unwrap_or_else(|e| BulkDnsResult { + id: Some(id), + success: false, + error: Some(e.to_string()), + }) + } + "delete" => { + let id = op.id.ok_or_else(|| AppError::Validation("id required".into()))?; + delete_record(pool, cf, domain_id, id) + .await + .map(|_| BulkDnsResult { id: Some(id), success: true, error: None }) + .unwrap_or_else(|e| BulkDnsResult { + id: Some(id), + success: false, + error: Some(e.to_string()), + }) + } + other => BulkDnsResult { + id: op.id, + success: false, + error: Some(format!("unknown action: {other}")), + }, + }; + results.push(res); + } + Ok(results) +} + +pub async fn resolve_conflict( + pool: &SqlitePool, + cf: &CloudflareClient, + domain_id: i64, + record_id: i64, + req: ResolveDnsRequest, +) -> AppResult { + let domain = domains::get(pool, domain_id).await?; + let record = dns_records::get(pool, domain_id, record_id).await?; + if record.sync_status != SYNC_CONFLICT { + return Err(AppError::Validation("record is not in conflict state".into())); + } + + match req.source.as_str() { + "cloudflare" => { + if let Some(cf_id) = &record.cf_record_id { + let remote = cf + .list_dns_records(&domain.cf_zone_id) + .await? + .into_iter() + .find(|r| r.id.as_deref() == Some(cf_id.as_str())); + if let Some(r) = remote { + dns_records::update_fields( + pool, + record_id, + &r.record_type, + &r.name, + &r.content, + r.ttl, + r.proxied.unwrap_or(false), + r.priority, + SYNC_SYNCED, + r.id.as_deref(), + None, + ) + .await?; + } + } + dns_records::get(pool, domain_id, record_id).await + } + "local" => { + let updated = dns_records::get(pool, domain_id, record_id).await?; + push_record(pool, cf, &domain, &updated).await + } + _ => Err(AppError::Validation("source must be cloudflare or local".into())), + } +} diff --git a/backend/src/services/domain_service.rs b/backend/src/services/domain_service.rs new file mode 100644 index 0000000..1de6327 --- /dev/null +++ b/backend/src/services/domain_service.rs @@ -0,0 +1,62 @@ +use crate::cloudflare::CloudflareClient; +use crate::domain::Domain; +use crate::error::{AppError, AppResult}; +use crate::repositories::{domains, services as service_repo}; +use sqlx::SqlitePool; + +pub async fn list_domains(pool: &SqlitePool, group_id: Option) -> AppResult> { + domains::list(pool, group_id).await +} + +pub async fn get_domain(pool: &SqlitePool, id: i64) -> AppResult { + domains::get(pool, id).await +} + +pub async fn create_domain( + pool: &SqlitePool, + cf: &CloudflareClient, + group_id: Option, + zone_name: &str, +) -> AppResult { + let zones = cf.list_zones().await?; + let zone = zones + .into_iter() + .find(|z| z.name == zone_name) + .ok_or_else(|| AppError::NotFound(format!("cloudflare zone {zone_name}")))?; + domains::create(pool, group_id, &zone.name, &zone.id).await +} + +pub async fn update_domain( + pool: &SqlitePool, + id: i64, + group_id: Option, + status: &str, +) -> AppResult { + domains::update(pool, id, group_id, status).await +} + +pub async fn delete_domain(pool: &SqlitePool, id: i64) -> AppResult<()> { + domains::delete(pool, id).await +} + +pub async fn set_domain_services( + pool: &SqlitePool, + domain_id: i64, + service_ids: Vec, +) -> AppResult> { + domains::get(pool, domain_id).await?; + for sid in &service_ids { + service_repo::get(pool, *sid).await?; + } + service_repo::set_domain_services(pool, domain_id, &service_ids).await?; + service_repo::list_domain_service_ids(pool, domain_id).await +} + +pub async fn import_zone_records( + pool: &SqlitePool, + cf: &CloudflareClient, + domain_id: i64, +) -> AppResult { + let domain = domains::get(pool, domain_id).await?; + crate::services::sync_service::pull_sync(pool, cf, &domain).await +} diff --git a/backend/src/services/group_service.rs b/backend/src/services/group_service.rs new file mode 100644 index 0000000..d86e4fe --- /dev/null +++ b/backend/src/services/group_service.rs @@ -0,0 +1,20 @@ +use crate::domain::Group; +use crate::error::AppResult; +use crate::repositories::groups; +use sqlx::SqlitePool; + +pub async fn list_groups(pool: &SqlitePool) -> AppResult> { + groups::list(pool).await +} + +pub async fn create_group(pool: &SqlitePool, name: &str, slug: &str) -> AppResult { + groups::create(pool, name, slug).await +} + +pub async fn update_group(pool: &SqlitePool, id: i64, name: &str, slug: &str) -> AppResult { + groups::update(pool, id, name, slug).await +} + +pub async fn delete_group(pool: &SqlitePool, id: i64) -> AppResult<()> { + groups::delete(pool, id).await +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs new file mode 100644 index 0000000..a77072a --- /dev/null +++ b/backend/src/services/mod.rs @@ -0,0 +1,6 @@ +pub mod auth; +pub mod certificate_service; +pub mod dns_service; +pub mod domain_service; +pub mod group_service; +pub mod sync_service; diff --git a/backend/src/services/sync_service.rs b/backend/src/services/sync_service.rs new file mode 100644 index 0000000..711ec15 --- /dev/null +++ b/backend/src/services/sync_service.rs @@ -0,0 +1,117 @@ +use crate::cloudflare::CloudflareClient; +use crate::domain::{Domain, SYNC_CONFLICT, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED}; +use crate::error::AppResult; +use crate::repositories::{dns_records, domains, sync_jobs}; +use sqlx::SqlitePool; +use uuid::Uuid; + +pub async fn pull_sync(pool: &SqlitePool, cf: &CloudflareClient, domain: &Domain) -> AppResult { + let remote = cf.list_dns_records(&domain.cf_zone_id).await?; + let local = dns_records::list_by_domain(pool, domain.id).await?; + let mut changed = 0usize; + + let remote_ids: std::collections::HashSet = remote + .iter() + .filter_map(|r| r.id.clone()) + .collect(); + + for cf_rec in &remote { + let cf_id = match &cf_rec.id { + Some(id) => id.as_str(), + None => continue, + }; + let proxied = cf_rec.proxied.unwrap_or(false); + + if let Some(existing) = dns_records::find_by_cf_id(pool, domain.id, cf_id).await? { + let content_match = existing.content == cf_rec.content + && existing.ttl == cf_rec.ttl + && existing.proxied == proxied + && existing.name == cf_rec.name + && existing.record_type.to_uppercase() == cf_rec.record_type.to_uppercase(); + + if !content_match && existing.sync_status != SYNC_PENDING_PUSH { + dns_records::set_sync_status( + pool, + existing.id, + SYNC_CONFLICT, + Some(cf_id), + None, + ) + .await?; + changed += 1; + } else if content_match && existing.sync_status == SYNC_CONFLICT { + dns_records::set_sync_status(pool, existing.id, SYNC_SYNCED, Some(cf_id), None).await?; + changed += 1; + } + } else { + dns_records::insert( + pool, + domain.id, + &cf_rec.record_type, + &cf_rec.name, + &cf_rec.content, + cf_rec.ttl, + proxied, + cf_rec.priority, + SYNC_SYNCED, + "cloudflare", + Some(cf_id), + ) + .await?; + changed += 1; + } + } + + for rec in &local { + if let Some(cf_id) = &rec.cf_record_id { + if !remote_ids.contains(cf_id) && rec.sync_status != SYNC_PENDING_DELETE { + dns_records::set_sync_status(pool, rec.id, SYNC_CONFLICT, Some(cf_id), Some("missing in cloudflare")).await?; + changed += 1; + } + } else if rec.sync_status == SYNC_PENDING_PUSH { + // push handled separately + } + } + + domains::set_last_synced(pool, domain.id).await?; + Ok(changed) +} + +pub async fn sync_domain( + pool: &SqlitePool, + cf: &CloudflareClient, + domain_id: i64, +) -> AppResult<(String, usize)> { + let job_id = Uuid::new_v4().to_string(); + sync_jobs::create(pool, &job_id, Some(domain_id)).await?; + let domain = domains::get(pool, domain_id).await?; + + let result = pull_sync(pool, cf, &domain).await; + match &result { + Ok(count) => { + sync_jobs::finish(pool, &job_id, "completed", Some(&format!("{count} changes"))).await?; + } + Err(e) => { + sync_jobs::finish(pool, &job_id, "failed", Some(&e.to_string())).await?; + } + } + result.map(|c| (job_id, c)) +} + +pub async fn sync_all(pool: &SqlitePool, cf: &CloudflareClient) -> AppResult { + let job_id = Uuid::new_v4().to_string(); + sync_jobs::create(pool, &job_id, None).await?; + let all = domains::list_all(pool).await?; + let mut total = 0usize; + for domain in all { + if let Ok(n) = pull_sync(pool, cf, &domain).await { + total += n; + } + } + sync_jobs::finish(pool, &job_id, "completed", Some(&format!("{total} total changes"))).await?; + Ok(job_id) +} + +pub async fn get_job(pool: &SqlitePool, job_id: &str) -> AppResult { + sync_jobs::get(pool, job_id).await +} diff --git a/backend/src/state.rs b/backend/src/state.rs new file mode 100644 index 0000000..5dde663 --- /dev/null +++ b/backend/src/state.rs @@ -0,0 +1,22 @@ +use crate::cloudflare::CloudflareClient; +use crate::config::Config; +use sqlx::SqlitePool; +use std::sync::Arc; + +#[derive(Clone)] +pub struct AppState { + pub pool: SqlitePool, + pub config: Arc, + pub cf: CloudflareClient, +} + +impl AppState { + pub fn new(pool: SqlitePool, config: Config) -> Self { + let cf = CloudflareClient::new(config.cloudflare_api_token.clone()); + Self { + pool, + config: Arc::new(config), + cf, + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2990c82 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + app: + build: . + ports: + - "8080:8080" + environment: + DATABASE_URL: sqlite:/data/app.db + CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:-} + JWT_SECRET: ${JWT_SECRET:-change-me-in-production} + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD_HASH: ${ADMIN_PASSWORD_HASH:-} + RUST_LOG: info + CERT_CHECK_CRON: "0 */6 * * *" + STATIC_DIR: /app/static + volumes: + - ./data:/data + restart: unless-stopped diff --git a/docs/Home.md b/docs/Home.md new file mode 100644 index 0000000..8b46178 --- /dev/null +++ b/docs/Home.md @@ -0,0 +1,27 @@ +# Cloudflare Domain Manager + +Wiki home — synced from repository on `main` when this file changes. + +## Overview + +Manage Cloudflare zones, DNS records, domain groups, and TLS certificate expiry from a single UI. + +## Configuration + +| Variable | Description | +|----------|-------------| +| `CLOUDFLARE_API_TOKEN` | API token with Zone.DNS permissions | +| `DATABASE_URL` | SQLite path (`sqlite:/data/app.db`) | +| `JWT_SECRET` | JWT signing secret | +| `ADMIN_USERNAME` | Admin username | +| `ADMIN_PASSWORD_HASH` | Argon2 hash (empty = dev `admin`/`admin`) | + +## Docker + +```bash +docker pull git.shts.su/denozord/cloudflare-domain-manager:latest +docker run -d -p 8080:8080 -v cfdm-data:/data \ + -e CLOUDFLARE_API_TOKEN=... \ + -e JWT_SECRET=... \ + git.shts.su/denozord/cloudflare-domain-manager:latest +``` diff --git a/docs/branch-protection.md b/docs/branch-protection.md new file mode 100644 index 0000000..427fb2d --- /dev/null +++ b/docs/branch-protection.md @@ -0,0 +1,16 @@ +# Branch protection (configure in Gitea UI) + +Apply these rules to **main** and **develop**: + +- [ ] Require pull request before merging +- [ ] Required approvals: 1 +- [ ] Require status checks: `test` +- [ ] Require branches to be up to date before merging +- [ ] Include administrators +- [ ] Prevent force push +- [ ] Prevent deletion + +Repository secrets required for CI: + +- `PACKAGE_TOKEN` — registry push +- `GITEA_TOKEN` — releases and wiki sync diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..ef614d2 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0fca6f0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..dd16ca4 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,5087 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@hookform/resolvers": "^5.4.0", + "@radix-ui/react-dialog": "^1.1.16", + "@radix-ui/react-dropdown-menu": "^2.1.17", + "@radix-ui/react-label": "^2.1.9", + "@radix-ui/react-select": "^2.3.0", + "@radix-ui/react-slot": "^1.2.5", + "@radix-ui/react-tabs": "^1.1.14", + "@tailwindcss/vite": "^4.3.1", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-router": "^1.170.15", + "@tanstack/react-table": "^8.21.3", + "@tanstack/router-vite-plugin": "^1.167.18", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.18.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-hook-form": "^7.79.0", + "recharts": "^3.8.1", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.1", + "zod": "^4.4.3" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12", + "vitest": "^4.1.8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@hookform/resolvers": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz", + "integrity": "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", + "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", + "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.9.tgz", + "integrity": "sha512-yqHW5WQ/cTpU/un7dqqIKNy2iRU8BC0JB78PEzTfCCYvZu1U6W9KwObAniMk9nhSfyotKPQTYaUD/HB0f5muig==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.5" + }, + "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": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.9.tgz", + "integrity": "sha512-zuSVi7ziP7uQRqc+yGxsKJfNkdyHv3ZKDaHe0gzg4dRgws96TPKWIiz84tVHP4GEcEl8bC0mdt17NkcxaJHmaQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-slot": "1.2.5" + }, + "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-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "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-context": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", + "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", + "license": "MIT", + "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-dialog": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.16.tgz", + "integrity": "sha512-l9ok83YBclEZhbjgzt76Hw733e6cvRKPNgO6GJ/IETlufXG9p+fRu2wlvpImQvR6xdJ8h7J8J2DBvsPEiEsKMw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.9", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-use-controllable-state": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.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-direction": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", + "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", + "license": "MIT", + "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-dismissable-layer": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.12.tgz", + "integrity": "sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-escape-keydown": "1.1.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-dropdown-menu": { + "version": "2.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.17.tgz", + "integrity": "sha512-S6b3Jm57sY5EdDyOMLkacbB0qMnKhy1RCKZCt795ZkmtUOAvojYIZ5p7dXHIh5Cyr3jCLLI5/g64V3FKLudZmw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.17", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "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-focus-guards": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", + "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "license": "MIT", + "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-focus-scope": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.9.tgz", + "integrity": "sha512-9Se8t+Zry+1rEOL7Y6l/4ANYU/TOtAtf8O2fKdwLltcaMcm6kOqYGbzO4tMFQ0bvzO920pRAoHpFZ4W85S3keQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.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-id": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "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-label": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.9.tgz", + "integrity": "sha512-rDoTeMbCwRVcnmo7NGT9IlPo1yXmEI+xc1URP3oeewwZEV4mdTp1dYUhYbQdo4D1q2SjKVvv4N1gNY77QAQtjA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.5" + }, + "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.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.17.tgz", + "integrity": "sha512-fmbNnFyf+JYCN0DhhWnEdUTDnZD1mXaPQWivdsPIb8oOSbARfD3LIQJbLCG8a8QLCwoMxiJ7GVPIFcC8Dw8v2Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.9", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.0", + "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-roving-focus": "1.1.12", + "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.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-popper": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.0.tgz", + "integrity": "sha512-9PB589e1aWZbrlFUHdz6WiPCL+xLZHQFX7oibqG/6Q0SwOkxDyQX9W/cyPa+sAPPKuC8cpLCpRczE5a/1DiwVQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-rect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/rect": "1.1.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-portal": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.11.tgz", + "integrity": "sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-layout-effect": "1.1.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-presence": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", + "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.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-primitive": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz", + "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.5" + }, + "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-roving-focus": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.12.tgz", + "integrity": "sha512-FvgPt1bRmg8Xt2QpF7NUZW3dE0ZQHGm41dAdgT2J2GJPoIXz+9Em3NobAxf4fupcxhgHu03E5CRiU2MWvObXyg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "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-select": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.0.tgz", + "integrity": "sha512-mENc7WpJvJcW8hlMpzfFcHcEhTvYS5JMBmi9HVC1Q00uhBwML086MHYUV8QQdQv6lcu0Wg8dzd1RB8AFADcG/g==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.12", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.9", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.0", + "@radix-ui/react-portal": "1.1.11", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-slot": "1.2.5", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.5", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.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-slot": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.5.tgz", + "integrity": "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3" + }, + "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-tabs": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.14.tgz", + "integrity": "sha512-D5jwp9JNuwDeCw3CYD2Fz+sSHo0droQjC8u75dJHe4aWr5q6yBiXZU+hurXnKudRgEpUkD5TsI6bjHPo5ThUxA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-roving-focus": "1.1.12", + "@radix-ui/react-use-controllable-state": "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": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", + "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "license": "MIT", + "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-controllable-state": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "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-effect-event": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "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-escape-keydown": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", + "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "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": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "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-previous": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", + "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", + "license": "MIT", + "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-rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", + "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.2" + }, + "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-size": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", + "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "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-visually-hidden": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.5.tgz", + "integrity": "sha512-tPcHNI3FajdDBFpl/Ez1m2WL0ufJqBKyHxMDBvKitopamK36WwBGOMicuMEZKkM5Wce41QxUyv6BsiqfrWBiGg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.5" + }, + "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/rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", + "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", + "license": "MIT" + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "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/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "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/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/history": { + "version": "1.162.0", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.0.tgz", + "integrity": "sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.170.15", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.15.tgz", + "integrity": "sha512-GawYz7HEjj8rTUUDoT/SemDEVm63pZUO+2mOcXHY9Jl3EwMS5gFBnPu/2UvcrwRm1jN1k79fokc0d4aFmrLatg==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.0", + "@tanstack/react-store": "^0.9.3", + "@tanstack/router-core": "1.171.13", + "isbot": "^5.1.22" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "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/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.171.13", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.171.13.tgz", + "integrity": "sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.0", + "cookie-es": "^3.0.0", + "seroval": "^1.5.4", + "seroval-plugins": "^1.5.4" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-generator": { + "version": "1.167.17", + "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.167.17.tgz", + "integrity": "sha512-xtB9tB2Ws0tWR6Pi7nc3Qk9IYgoh1mQCKWjHqIl9tf6BNUpKoqniJoPAQ4+LGrK8FeZYU0o0p/qlZEyj9FAulA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5", + "@tanstack/router-core": "1.171.13", + "@tanstack/router-utils": "1.162.2", + "@tanstack/virtual-file-routes": "1.162.0", + "jiti": "^2.7.0", + "magic-string": "^0.30.21", + "prettier": "^3.5.0", + "zod": "^4.4.3" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-plugin": { + "version": "1.168.18", + "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.168.18.tgz", + "integrity": "sha512-MofS28/axfnfnhOD2RSgJEaU882aX5RsAzhGz5Vc4XhAmvCjy919u9JrNs4QsTWFbTD1P7IJ8WFlFVsrg0pStg==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "@tanstack/router-core": "1.171.13", + "@tanstack/router-generator": "1.167.17", + "@tanstack/router-utils": "1.162.2", + "chokidar": "^5.0.0", + "unplugin": "^3.0.0", + "zod": "^4.4.3" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@rsbuild/core": ">=1.0.2 || ^2.0.0", + "@tanstack/react-router": "^1.170.15", + "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", + "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", + "webpack": ">=5.92.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + }, + "@tanstack/react-router": { + "optional": true + }, + "vite": { + "optional": true + }, + "vite-plugin-solid": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/@tanstack/router-utils": { + "version": "1.162.2", + "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.162.2.tgz", + "integrity": "sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.28.5", + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "ansis": "^4.1.0", + "babel-dead-code-elimination": "^1.0.12", + "diff": "^8.0.2", + "pathe": "^2.0.3", + "tinyglobby": "^0.2.15" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-vite-plugin": { + "version": "1.167.18", + "resolved": "https://registry.npmjs.org/@tanstack/router-vite-plugin/-/router-vite-plugin-1.167.18.tgz", + "integrity": "sha512-JG5kSvzF1UJasJRLO3oXiuJp1F2OIrRJuZpJ6JAfU+k1gOStHFlSP/X9xArnhtEKvitYNCN+26yn4ku8X0DP7w==", + "license": "MIT", + "dependencies": { + "@tanstack/router-plugin": "1.168.18" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-file-routes": { + "version": "1.162.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.162.0.tgz", + "integrity": "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "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": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", + "integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/type-utils": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.61.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz", + "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz", + "integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.61.0", + "@typescript-eslint/types": "^8.61.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz", + "integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz", + "integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz", + "integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz", + "integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz", + "integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.61.0", + "@typescript-eslint/tsconfig-utils": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz", + "integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz", + "integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansis": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz", + "integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-dead-code-elimination": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", + "integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.7", + "@babel/parser": "^7.23.6", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.372", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", + "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-toolkit": { + "version": "1.47.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz", + "integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", + "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isbot": { + "version": "5.1.42", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.42.tgz", + "integrity": "sha512-/SXsVh7KpPRISrD4ffrGSxnTLlUBzEQUfWIusaJPrpJ93FW1P0YEZri5vAUkFsA0m2HRUhQRQadk2wJ+EeKowQ==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.18.0.tgz", + "integrity": "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-hook-form": { + "version": "7.79.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.79.0.tgz", + "integrity": "sha512-mhYp/MTmXvzYX6AJcJVko0rktoIhhmRnEouObj4wF5i/tCttgJvnp1+9wRkpITZjDTqpo4IOSJqu0dBlPlV/Lw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-is": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/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-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.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": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "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", + "peerDependencies": { + "redux": "^5.0.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/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/seroval": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.4.tgz", + "integrity": "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.4.tgz", + "integrity": "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz", + "integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.61.0", + "@typescript-eslint/parser": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unplugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..3a5e6c7 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,52 @@ +{ + "name": "cfdm-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "test": "vitest run", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@hookform/resolvers": "^5.4.0", + "@radix-ui/react-dialog": "^1.1.16", + "@radix-ui/react-dropdown-menu": "^2.1.17", + "@radix-ui/react-label": "^2.1.9", + "@radix-ui/react-select": "^2.3.0", + "@radix-ui/react-slot": "^1.2.5", + "@radix-ui/react-tabs": "^1.1.14", + "@tailwindcss/vite": "^4.3.1", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-router": "^1.170.15", + "@tanstack/react-table": "^8.21.3", + "@tanstack/router-vite-plugin": "^1.167.18", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.18.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-hook-form": "^7.79.0", + "recharts": "^3.8.1", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.1", + "zod": "^4.4.3" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12", + "vitest": "^4.1.8" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/layout/app-shell.tsx b/frontend/src/components/layout/app-shell.tsx new file mode 100644 index 0000000..cc7f198 --- /dev/null +++ b/frontend/src/components/layout/app-shell.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from 'react' +import { Link } from '@tanstack/react-router' + +const links = [ + { to: '/', label: 'Dashboard' }, + { to: '/domains', label: 'Domains' }, + { to: '/groups', label: 'Groups' }, + { to: '/services', label: 'Services' }, + { to: '/certificates', label: 'Certificates' }, +] as const + +export function AppShell({ children }: { children: ReactNode }) { + return ( +
+ +
{children}
+
+ ) +} diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..12ec015 --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -0,0 +1,26 @@ +import { cn } from '@/lib/utils' + +const variants: Record = { + synced: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-100', + pending_push: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100', + conflict: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100', + error: 'bg-red-100 text-red-800', + ok: 'bg-emerald-100 text-emerald-800', + warning: 'bg-amber-100 text-amber-800', + expired: 'bg-red-100 text-red-800', + unknown: 'bg-muted text-muted-foreground', +} + +export function Badge({ status, className }: { status: string; className?: string }) { + return ( + + {status} + + ) +} diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..34a5259 --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,34 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '@/lib/utils' + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:opacity-90', + outline: 'border border-border bg-background hover:bg-muted', + destructive: 'bg-destructive text-white hover:opacity-90', + ghost: 'hover:bg-muted', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + }, + }, + defaultVariants: { variant: 'default', size: 'default' }, + }, +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +export function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) { + const Comp = asChild ? Slot : 'button' + return +} diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..2878cc3 --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +export function Card({ className, children }: { className?: string; children: ReactNode }) { + return ( +
+ {children} +
+ ) +} + +export function CardTitle({ children }: { children: ReactNode }) { + return

{children}

+} diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..37a3e89 --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,14 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +export function Input({ className, ...props }: React.InputHTMLAttributes) { + return ( + + ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..c674ff9 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,33 @@ +@import "tailwindcss"; + +@theme { + --color-background: oklch(100% 0 0); + --color-foreground: oklch(14.5% 0 0); + --color-primary: oklch(45% 0.15 250); + --color-primary-foreground: oklch(98% 0 0); + --color-muted: oklch(96% 0 0); + --color-muted-foreground: oklch(45% 0 0); + --color-border: oklch(90% 0 0); + --color-destructive: oklch(55% 0.2 25); + --color-card: oklch(100% 0 0); + --radius-lg: 0.5rem; +} + +@media (prefers-color-scheme: dark) { + @theme { + --color-background: oklch(14% 0 0); + --color-foreground: oklch(98% 0 0); + --color-primary: oklch(65% 0.15 250); + --color-primary-foreground: oklch(14% 0 0); + --color-muted: oklch(22% 0 0); + --color-muted-foreground: oklch(70% 0 0); + --color-border: oklch(28% 0 0); + --color-card: oklch(18% 0 0); + } +} + +body { + @apply bg-background text-foreground antialiased; + margin: 0; + min-height: 100vh; +} diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts new file mode 100644 index 0000000..6b7a608 --- /dev/null +++ b/frontend/src/lib/api-client.ts @@ -0,0 +1,46 @@ +export class ApiError extends Error { + constructor( + public status: number, + public code: string, + message: string, + ) { + super(message) + this.name = 'ApiError' + } +} + +async function request(path: string, init?: RequestInit): Promise { + const token = localStorage.getItem('cfdm_token') + const headers = new Headers(init?.headers) + headers.set('Content-Type', 'application/json') + if (token) headers.set('Authorization', `Bearer ${token}`) + + const res = await fetch(path, { ...init, headers }) + if (res.status === 401 && !path.includes('/auth/login')) { + localStorage.removeItem('cfdm_token') + window.location.href = '/login' + throw new ApiError(401, 'UNAUTHORIZED', 'Unauthorized') + } + if (!res.ok) { + const body = await res.json().catch(() => ({})) + const err = body?.error + throw new ApiError( + res.status, + err?.code ?? 'UNKNOWN', + err?.message ?? res.statusText, + ) + } + if (res.status === 204) return undefined as T + return res.json() as Promise +} + +export const api = { + get: (path: string) => request(path), + post: (path: string, body?: unknown) => + request(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }), + patch: (path: string, body: unknown) => + request(path, { method: 'PATCH', body: JSON.stringify(body) }), + put: (path: string, body: unknown) => + request(path, { method: 'PUT', body: JSON.stringify(body) }), + delete: (path: string) => request(path, { method: 'DELETE' }), +} diff --git a/frontend/src/lib/queryClient.ts b/frontend/src/lib/queryClient.ts new file mode 100644 index 0000000..86d4848 --- /dev/null +++ b/frontend/src/lib/queryClient.ts @@ -0,0 +1,14 @@ +import { QueryClient } from '@tanstack/react-query' +import { ApiError } from './api-client' + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60, + retry: (count, error) => { + if (error instanceof ApiError && error.status === 404) return false + return count < 2 + }, + }, + }, +}) diff --git a/frontend/src/lib/schemas-ext.ts b/frontend/src/lib/schemas-ext.ts new file mode 100644 index 0000000..255567d --- /dev/null +++ b/frontend/src/lib/schemas-ext.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' + +export const subdomainSchema = z.object({ + id: z.number(), + domain_id: z.number(), + name: z.string(), + fqdn: z.string(), + created_at: z.string(), + updated_at: z.string(), +}) + +export type Subdomain = z.infer diff --git a/frontend/src/lib/schemas.ts b/frontend/src/lib/schemas.ts new file mode 100644 index 0000000..b10be7c --- /dev/null +++ b/frontend/src/lib/schemas.ts @@ -0,0 +1,64 @@ +import { z } from 'zod' + +export const groupSchema = z.object({ + id: z.number(), + name: z.string(), + slug: z.string(), + created_at: z.string(), + updated_at: z.string(), +}) + +export const serviceSchema = z.object({ + id: z.number(), + name: z.string(), + slug: z.string(), + created_at: z.string(), + updated_at: z.string(), +}) + +export const domainSchema = z.object({ + id: z.number(), + group_id: z.number().nullable(), + zone_name: z.string(), + cf_zone_id: z.string(), + status: z.string(), + last_synced_at: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), +}) + +export const dnsRecordSchema = z.object({ + id: z.number(), + domain_id: z.number(), + cf_record_id: z.string().nullable(), + record_type: z.string(), + name: z.string(), + content: z.string(), + ttl: z.number(), + proxied: z.boolean(), + priority: z.number().nullable(), + sync_status: z.string(), + origin: z.string(), + last_error: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), +}) + +export const certificateSchema = z.object({ + id: z.number(), + domain_id: z.number(), + subdomain_id: z.number().nullable(), + hostname: z.string(), + expires_at: z.string().nullable(), + last_checked_at: z.string().nullable(), + last_error: z.string().nullable(), + status: z.string(), + created_at: z.string(), + updated_at: z.string(), +}) + +export type Group = z.infer +export type Service = z.infer +export type Domain = z.infer +export type DnsRecord = z.infer +export type Certificate = z.infer diff --git a/frontend/src/lib/utils.test.ts b/frontend/src/lib/utils.test.ts new file mode 100644 index 0000000..fef727d --- /dev/null +++ b/frontend/src/lib/utils.test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from 'vitest' +import { cn } from './utils' + +describe('cn', () => { + it('merges classes', () => { + expect(cn('a', 'b')).toBe('a b') + }) +}) diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..0628d1a --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,18 @@ +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +export function getToken(): string | null { + return localStorage.getItem('cfdm_token') +} + +export function setToken(token: string) { + localStorage.setItem('cfdm_token', token) +} + +export function clearToken() { + localStorage.removeItem('cfdm_token') +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..146b9b3 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,27 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { RouterProvider, createRouter } from '@tanstack/react-router' +import { QueryClientProvider } from '@tanstack/react-query' +import { routeTree } from './routeTree.gen' +import { queryClient } from './lib/queryClient' +import './index.css' + +const router = createRouter({ + routeTree, + context: { queryClient }, + defaultPreload: 'intent', +}) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +createRoot(document.getElementById('root')!).render( + + + + + , +) diff --git a/frontend/src/queries/index.ts b/frontend/src/queries/index.ts new file mode 100644 index 0000000..39f309f --- /dev/null +++ b/frontend/src/queries/index.ts @@ -0,0 +1,91 @@ +import { queryOptions } from '@tanstack/react-query' +import { api } from '@/lib/api-client' +import { certificateSchema, dnsRecordSchema, domainSchema, groupSchema, serviceSchema } from '@/lib/schemas' +import { z } from 'zod' + +export const groupKeys = { + all: ['groups'] as const, +} + +export const groupsQueryOptions = () => + queryOptions({ + queryKey: groupKeys.all, + queryFn: async () => { + const data = await api.get('/api/v1/groups') + return z.array(groupSchema).parse(data) + }, + }) + +export const serviceKeys = { + all: ['services'] as const, +} + +export const servicesQueryOptions = () => + queryOptions({ + queryKey: serviceKeys.all, + queryFn: async () => { + const data = await api.get('/api/v1/services') + return z.array(serviceSchema).parse(data) + }, + }) + +export const domainKeys = { + all: ['domains'] as const, + list: (groupId?: number) => [...domainKeys.all, 'list', groupId] as const, + detail: (id: number) => [...domainKeys.all, 'detail', id] as const, +} + +export const domainsListQueryOptions = (groupId?: number) => + queryOptions({ + queryKey: domainKeys.list(groupId), + queryFn: async () => { + const qs = groupId ? `?group_id=${groupId}` : '' + const data = await api.get(`/api/v1/domains${qs}`) + return z.array(domainSchema).parse(data) + }, + }) + +export const domainDetailQueryOptions = (id: number) => + queryOptions({ + queryKey: domainKeys.detail(id), + queryFn: async () => { + const data = await api.get(`/api/v1/domains/${id}`) + return domainSchema.parse(data) + }, + }) + +export const dnsKeys = { + all: ['dns'] as const, + list: (domainId: number) => [...dnsKeys.all, domainId] as const, +} + +export const dnsListQueryOptions = (domainId: number) => + queryOptions({ + queryKey: dnsKeys.list(domainId), + queryFn: async () => { + const data = await api.get(`/api/v1/domains/${domainId}/dns`) + return z.array(dnsRecordSchema).parse(data) + }, + staleTime: 1000 * 30, + }) + +export const certKeys = { + all: ['certificates'] as const, + summary: ['certificates', 'summary'] as const, +} + +export const certificatesQueryOptions = () => + queryOptions({ + queryKey: certKeys.all, + queryFn: async () => { + const data = await api.get('/api/v1/certificates') + return z.array(certificateSchema).parse(data) + }, + staleTime: 1000 * 60 * 5, + }) + +export const certSummaryQueryOptions = () => + queryOptions({ + queryKey: certKeys.summary, + queryFn: () => api.get<[string, number][]>('/api/v1/certificates/summary'), + }) diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts new file mode 100644 index 0000000..50179e8 --- /dev/null +++ b/frontend/src/routeTree.gen.ts @@ -0,0 +1,235 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as LoginRouteImport } from './routes/login' +import { Route as AuthRouteImport } from './routes/_auth' +import { Route as AuthIndexRouteImport } from './routes/_auth/index' +import { Route as AuthServicesRouteImport } from './routes/_auth/services' +import { Route as AuthGroupsRouteImport } from './routes/_auth/groups' +import { Route as AuthCertificatesRouteImport } from './routes/_auth/certificates' +import { Route as AuthDomainsIndexRouteImport } from './routes/_auth/domains/index' +import { Route as AuthDomainsDomainIdIndexRouteImport } from './routes/_auth/domains/$domainId/index' +import { Route as AuthDomainsDomainIdDnsRouteImport } from './routes/_auth/domains/$domainId/dns' + +const LoginRoute = LoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => rootRouteImport, +} as any) +const AuthRoute = AuthRouteImport.update({ + id: '/_auth', + getParentRoute: () => rootRouteImport, +} as any) +const AuthIndexRoute = AuthIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AuthRoute, +} as any) +const AuthServicesRoute = AuthServicesRouteImport.update({ + id: '/services', + path: '/services', + getParentRoute: () => AuthRoute, +} as any) +const AuthGroupsRoute = AuthGroupsRouteImport.update({ + id: '/groups', + path: '/groups', + getParentRoute: () => AuthRoute, +} as any) +const AuthCertificatesRoute = AuthCertificatesRouteImport.update({ + id: '/certificates', + path: '/certificates', + getParentRoute: () => AuthRoute, +} as any) +const AuthDomainsIndexRoute = AuthDomainsIndexRouteImport.update({ + id: '/domains/', + path: '/domains/', + getParentRoute: () => AuthRoute, +} as any) +const AuthDomainsDomainIdIndexRoute = + AuthDomainsDomainIdIndexRouteImport.update({ + id: '/domains/$domainId/', + path: '/domains/$domainId/', + getParentRoute: () => AuthRoute, + } as any) +const AuthDomainsDomainIdDnsRoute = AuthDomainsDomainIdDnsRouteImport.update({ + id: '/domains/$domainId/dns', + path: '/domains/$domainId/dns', + getParentRoute: () => AuthRoute, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof AuthIndexRoute + '/login': typeof LoginRoute + '/certificates': typeof AuthCertificatesRoute + '/groups': typeof AuthGroupsRoute + '/services': typeof AuthServicesRoute + '/domains/': typeof AuthDomainsIndexRoute + '/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute + '/domains/$domainId/': typeof AuthDomainsDomainIdIndexRoute +} +export interface FileRoutesByTo { + '/login': typeof LoginRoute + '/certificates': typeof AuthCertificatesRoute + '/groups': typeof AuthGroupsRoute + '/services': typeof AuthServicesRoute + '/': typeof AuthIndexRoute + '/domains': typeof AuthDomainsIndexRoute + '/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute + '/domains/$domainId': typeof AuthDomainsDomainIdIndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/_auth': typeof AuthRouteWithChildren + '/login': typeof LoginRoute + '/_auth/certificates': typeof AuthCertificatesRoute + '/_auth/groups': typeof AuthGroupsRoute + '/_auth/services': typeof AuthServicesRoute + '/_auth/': typeof AuthIndexRoute + '/_auth/domains/': typeof AuthDomainsIndexRoute + '/_auth/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute + '/_auth/domains/$domainId/': typeof AuthDomainsDomainIdIndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/login' + | '/certificates' + | '/groups' + | '/services' + | '/domains/' + | '/domains/$domainId/dns' + | '/domains/$domainId/' + fileRoutesByTo: FileRoutesByTo + to: + | '/login' + | '/certificates' + | '/groups' + | '/services' + | '/' + | '/domains' + | '/domains/$domainId/dns' + | '/domains/$domainId' + id: + | '__root__' + | '/_auth' + | '/login' + | '/_auth/certificates' + | '/_auth/groups' + | '/_auth/services' + | '/_auth/' + | '/_auth/domains/' + | '/_auth/domains/$domainId/dns' + | '/_auth/domains/$domainId/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + AuthRoute: typeof AuthRouteWithChildren + LoginRoute: typeof LoginRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } + '/_auth': { + id: '/_auth' + path: '' + fullPath: '/' + preLoaderRoute: typeof AuthRouteImport + parentRoute: typeof rootRouteImport + } + '/_auth/': { + id: '/_auth/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof AuthIndexRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/services': { + id: '/_auth/services' + path: '/services' + fullPath: '/services' + preLoaderRoute: typeof AuthServicesRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/groups': { + id: '/_auth/groups' + path: '/groups' + fullPath: '/groups' + preLoaderRoute: typeof AuthGroupsRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/certificates': { + id: '/_auth/certificates' + path: '/certificates' + fullPath: '/certificates' + preLoaderRoute: typeof AuthCertificatesRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/domains/': { + id: '/_auth/domains/' + path: '/domains' + fullPath: '/domains/' + preLoaderRoute: typeof AuthDomainsIndexRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/domains/$domainId/': { + id: '/_auth/domains/$domainId/' + path: '/domains/$domainId' + fullPath: '/domains/$domainId/' + preLoaderRoute: typeof AuthDomainsDomainIdIndexRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/domains/$domainId/dns': { + id: '/_auth/domains/$domainId/dns' + path: '/domains/$domainId/dns' + fullPath: '/domains/$domainId/dns' + preLoaderRoute: typeof AuthDomainsDomainIdDnsRouteImport + parentRoute: typeof AuthRoute + } + } +} + +interface AuthRouteChildren { + AuthCertificatesRoute: typeof AuthCertificatesRoute + AuthGroupsRoute: typeof AuthGroupsRoute + AuthServicesRoute: typeof AuthServicesRoute + AuthIndexRoute: typeof AuthIndexRoute + AuthDomainsIndexRoute: typeof AuthDomainsIndexRoute + AuthDomainsDomainIdDnsRoute: typeof AuthDomainsDomainIdDnsRoute + AuthDomainsDomainIdIndexRoute: typeof AuthDomainsDomainIdIndexRoute +} + +const AuthRouteChildren: AuthRouteChildren = { + AuthCertificatesRoute: AuthCertificatesRoute, + AuthGroupsRoute: AuthGroupsRoute, + AuthServicesRoute: AuthServicesRoute, + AuthIndexRoute: AuthIndexRoute, + AuthDomainsIndexRoute: AuthDomainsIndexRoute, + AuthDomainsDomainIdDnsRoute: AuthDomainsDomainIdDnsRoute, + AuthDomainsDomainIdIndexRoute: AuthDomainsDomainIdIndexRoute, +} + +const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + AuthRoute: AuthRouteWithChildren, + LoginRoute: LoginRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx new file mode 100644 index 0000000..f90677d --- /dev/null +++ b/frontend/src/routes/__root.tsx @@ -0,0 +1,21 @@ +import { createRootRouteWithContext, Outlet, redirect } from '@tanstack/react-router' +import type { QueryClient } from '@tanstack/react-query' +import { getToken } from '@/lib/utils' + +export interface RouterContext { + queryClient: QueryClient +} + +export const Route = createRootRouteWithContext()({ + component: () => , + beforeLoad: ({ location }) => { + const isLogin = location.pathname === '/login' + const token = getToken() + if (!token && !isLogin) { + throw redirect({ to: '/login' }) + } + if (token && isLogin) { + throw redirect({ to: '/' }) + } + }, +}) diff --git a/frontend/src/routes/_auth.tsx b/frontend/src/routes/_auth.tsx new file mode 100644 index 0000000..341f838 --- /dev/null +++ b/frontend/src/routes/_auth.tsx @@ -0,0 +1,10 @@ +import { createFileRoute, Outlet } from '@tanstack/react-router' +import { AppShell } from '@/components/layout/app-shell' + +export const Route = createFileRoute('/_auth')({ + component: () => ( + + + + ), +}) diff --git a/frontend/src/routes/_auth/certificates.tsx b/frontend/src/routes/_auth/certificates.tsx new file mode 100644 index 0000000..a434905 --- /dev/null +++ b/frontend/src/routes/_auth/certificates.tsx @@ -0,0 +1,79 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts' +import { certificatesQueryOptions, certKeys, certSummaryQueryOptions } from '@/queries' +import { api } from '@/lib/api-client' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardTitle } from '@/components/ui/card' + +export const Route = createFileRoute('/_auth/certificates')({ + loader: ({ context: { queryClient } }) => + Promise.all([ + queryClient.ensureQueryData(certificatesQueryOptions()), + queryClient.ensureQueryData(certSummaryQueryOptions()), + ]), + component: CertificatesPage, +}) + +function CertificatesPage() { + const queryClient = useQueryClient() + const { data: certs } = useQuery(certificatesQueryOptions()) + const { data: summary } = useQuery(certSummaryQueryOptions()) + + const checkMutation = useMutation({ + mutationFn: () => api.post('/api/v1/certificates/check'), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: certKeys.all }) + queryClient.invalidateQueries({ queryKey: certKeys.summary }) + }, + }) + + const chartData = summary?.map(([status, count]) => ({ status, count })) ?? [] + + return ( +
+
+

Certificates

+ +
+ + Status overview +
+ + + + + + + + +
+
+
+ + + + + + + + + + + {certs?.map((c) => ( + + + + + + + ))} + +
HostnameStatusExpiresLast checked
{c.hostname}{c.expires_at ?? '—'}{c.last_checked_at ?? '—'}
+
+
+ ) +} diff --git a/frontend/src/routes/_auth/domains/$domainId/dns.tsx b/frontend/src/routes/_auth/domains/$domainId/dns.tsx new file mode 100644 index 0000000..c42020a --- /dev/null +++ b/frontend/src/routes/_auth/domains/$domainId/dns.tsx @@ -0,0 +1,99 @@ +import { createFileRoute, Link } from '@tanstack/react-router' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { domainDetailQueryOptions, dnsKeys, dnsListQueryOptions } from '@/queries' +import { api } from '@/lib/api-client' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import { Card, CardTitle } from '@/components/ui/card' +import { useState } from 'react' + +export const Route = createFileRoute('/_auth/domains/$domainId/dns')({ + loader: ({ context: { queryClient }, params }) => { + const id = Number(params.domainId) + return Promise.all([ + queryClient.ensureQueryData(domainDetailQueryOptions(id)), + queryClient.ensureQueryData(dnsListQueryOptions(id)), + ]) + }, + component: DnsPage, +}) + +function DnsPage() { + const { domainId } = Route.useParams() + const id = Number(domainId) + const queryClient = useQueryClient() + const { data: domain } = useQuery(domainDetailQueryOptions(id)) + const { data: records } = useQuery(dnsListQueryOptions(id)) + const [form, setForm] = useState({ record_type: 'A', name: '@', content: '', ttl: 1, proxied: false }) + + const syncMutation = useMutation({ + mutationFn: () => api.post(`/api/v1/domains/${id}/sync`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: dnsKeys.list(id) }) + queryClient.invalidateQueries({ queryKey: ['domains'] }) + }, + }) + + const createMutation = useMutation({ + mutationFn: () => api.post(`/api/v1/domains/${id}/dns`, form), + onSuccess: () => queryClient.invalidateQueries({ queryKey: dnsKeys.list(id) }), + }) + + const deleteMutation = useMutation({ + mutationFn: (recordId: number) => api.delete(`/api/v1/domains/${id}/dns/${recordId}`), + onSuccess: () => queryClient.invalidateQueries({ queryKey: dnsKeys.list(id) }), + }) + + return ( +
+
+
+ ← Domains +

{domain?.zone_name} — DNS

+
+ +
+ + New record +
+ setForm({ ...form, record_type: e.target.value })} placeholder="Type" aria-label="Type" /> + setForm({ ...form, name: e.target.value })} placeholder="Name" aria-label="Name" /> + setForm({ ...form, content: e.target.value })} placeholder="Content" aria-label="Content" className="md:col-span-2" /> + setForm({ ...form, ttl: Number(e.target.value) })} placeholder="TTL" aria-label="TTL" /> + +
+
+
+ + + + + + + + + + + + + {records?.map((r) => ( + + + + + + + + + ))} + +
TypeNameContentTTLSync
{r.record_type}{r.name}{r.content}{r.ttl} + +
+
+
+ ) +} diff --git a/frontend/src/routes/_auth/domains/$domainId/index.tsx b/frontend/src/routes/_auth/domains/$domainId/index.tsx new file mode 100644 index 0000000..ed19659 --- /dev/null +++ b/frontend/src/routes/_auth/domains/$domainId/index.tsx @@ -0,0 +1,43 @@ +import { createFileRoute, Link } from '@tanstack/react-router' +import { useQuery } from '@tanstack/react-query' +import { api } from '@/lib/api-client' +import { domainDetailQueryOptions } from '@/queries' +import { z } from 'zod' +import { subdomainSchema } from '@/lib/schemas-ext' + +const subdomainListSchema = z.array(subdomainSchema) + +export const Route = createFileRoute('/_auth/domains/$domainId/')({ + loader: ({ context: { queryClient }, params }) => + queryClient.ensureQueryData(domainDetailQueryOptions(Number(params.domainId))), + component: DomainOverviewPage, +}) + +function DomainOverviewPage() { + const { domainId } = Route.useParams() + const id = Number(domainId) + const { data: domain } = useQuery(domainDetailQueryOptions(id)) + const { data: subdomains } = useQuery({ + queryKey: ['subdomains', id], + queryFn: async () => { + const data = await api.get(`/api/v1/domains/${id}/subdomains`) + return subdomainListSchema.parse(data) + }, + }) + + return ( +
+ ← Domains +

{domain?.zone_name}

+
+ DNS records +
+

Subdomains

+
    + {subdomains?.map((s) => ( +
  • {s.fqdn}
  • + ))} +
+
+ ) +} diff --git a/frontend/src/routes/_auth/domains/index.tsx b/frontend/src/routes/_auth/domains/index.tsx new file mode 100644 index 0000000..d89362d --- /dev/null +++ b/frontend/src/routes/_auth/domains/index.tsx @@ -0,0 +1,91 @@ +import { createFileRoute, Link } from '@tanstack/react-router' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { api } from '@/lib/api-client' +import { domainKeys, domainsListQueryOptions, groupsQueryOptions } from '@/queries' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Card, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' + +export const Route = createFileRoute('/_auth/domains/')({ + loader: ({ context: { queryClient } }) => + Promise.all([ + queryClient.ensureQueryData(domainsListQueryOptions()), + queryClient.ensureQueryData(groupsQueryOptions()), + ]), + component: DomainsPage, +}) + +function DomainsPage() { + const [zoneName, setZoneName] = useState('') + const [groupFilter, setGroupFilter] = useState() + const queryClient = useQueryClient() + const { data: domains } = useQuery(domainsListQueryOptions(groupFilter)) + const { data: groups } = useQuery(groupsQueryOptions()) + + const createMutation = useMutation({ + mutationFn: (body: { zone_name: string; group_id?: number }) => + api.post('/api/v1/domains', body), + onSuccess: () => queryClient.invalidateQueries({ queryKey: domainKeys.all }), + }) + + const handleCreate = () => { + if (!zoneName.trim()) return + createMutation.mutate({ zone_name: zoneName.trim(), group_id: groupFilter }) + setZoneName('') + } + + return ( +
+

Domains

+ + Add domain from Cloudflare +
+ setZoneName(e.target.value)} placeholder="example.com" aria-label="Zone name" /> + + +
+
+
+ + + + + + + + + + + {domains?.map((d) => ( + + + + + + + ))} + +
ZoneStatusLast syncActions
{d.zone_name}{d.last_synced_at ?? '—'} + + View + + + DNS + +
+
+
+ ) +} diff --git a/frontend/src/routes/_auth/groups.tsx b/frontend/src/routes/_auth/groups.tsx new file mode 100644 index 0000000..ecebd61 --- /dev/null +++ b/frontend/src/routes/_auth/groups.tsx @@ -0,0 +1,51 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { groupsQueryOptions, groupKeys } from '@/queries' +import { api } from '@/lib/api-client' +import { Card, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { useState } from 'react' + +export const Route = createFileRoute('/_auth/groups')({ + loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(groupsQueryOptions()), + component: GroupsPage, +}) + +function GroupsPage() { + const queryClient = useQueryClient() + const { data: groups } = useQuery(groupsQueryOptions()) + const [name, setName] = useState('') + const [slug, setSlug] = useState('') + + const createMutation = useMutation({ + mutationFn: () => api.post('/api/v1/groups', { name, slug }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: groupKeys.all }) + setName('') + setSlug('') + }, + }) + + return ( +
+

Groups

+ + Create group +
+ setName(e.target.value)} placeholder="Name" aria-label="Name" /> + setSlug(e.target.value)} placeholder="slug" aria-label="Slug" /> + +
+
+
    + {groups?.map((g) => ( +
  • + {g.name} + ({g.slug}) +
  • + ))} +
+
+ ) +} diff --git a/frontend/src/routes/_auth/index.tsx b/frontend/src/routes/_auth/index.tsx new file mode 100644 index 0000000..a2de1c1 --- /dev/null +++ b/frontend/src/routes/_auth/index.tsx @@ -0,0 +1,50 @@ +import { createFileRoute, Link } from '@tanstack/react-router' +import { useQuery } from '@tanstack/react-query' +import { Card, CardTitle } from '@/components/ui/card' +import { certificatesQueryOptions, certSummaryQueryOptions, domainsListQueryOptions } from '@/queries' + +export const Route = createFileRoute('/_auth/')({ + loader: ({ context: { queryClient } }) => + Promise.all([ + queryClient.ensureQueryData(domainsListQueryOptions()), + queryClient.ensureQueryData(certSummaryQueryOptions()), + ]), + component: DashboardPage, +}) + +function DashboardPage() { + const { data: domains } = useQuery(domainsListQueryOptions()) + const { data: summary } = useQuery(certSummaryQueryOptions()) + const { data: certs } = useQuery(certificatesQueryOptions()) + + return ( +
+

Dashboard

+
+ + Domains +

{domains?.length ?? 0}

+
+ + Certificates +

{certs?.length ?? 0}

+
+ + Cert status +
    + {summary?.map(([s, n]) => ( +
  • {s}: {n}
  • + ))} +
+
+
+ + Quick links +
+ Manage domains + Certificate dashboard +
+
+
+ ) +} diff --git a/frontend/src/routes/_auth/services.tsx b/frontend/src/routes/_auth/services.tsx new file mode 100644 index 0000000..def27b5 --- /dev/null +++ b/frontend/src/routes/_auth/services.tsx @@ -0,0 +1,51 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { servicesQueryOptions, serviceKeys } from '@/queries' +import { api } from '@/lib/api-client' +import { Card, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { useState } from 'react' + +export const Route = createFileRoute('/_auth/services')({ + loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(servicesQueryOptions()), + component: ServicesPage, +}) + +function ServicesPage() { + const queryClient = useQueryClient() + const { data: services } = useQuery(servicesQueryOptions()) + const [name, setName] = useState('') + const [slug, setSlug] = useState('') + + const createMutation = useMutation({ + mutationFn: () => api.post('/api/v1/services', { name, slug }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: serviceKeys.all }) + setName('') + setSlug('') + }, + }) + + return ( +
+

Services

+ + Create service +
+ setName(e.target.value)} placeholder="Name" aria-label="Name" /> + setSlug(e.target.value)} placeholder="slug" aria-label="Slug" /> + +
+
+
    + {services?.map((s) => ( +
  • + {s.name} + ({s.slug}) +
  • + ))} +
+
+ ) +} diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx new file mode 100644 index 0000000..8477400 --- /dev/null +++ b/frontend/src/routes/login.tsx @@ -0,0 +1,44 @@ +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { useState } from 'react' +import { api } from '@/lib/api-client' +import { setToken } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Card, CardTitle } from '@/components/ui/card' + +export const Route = createFileRoute('/login')({ + component: LoginPage, +}) + +function LoginPage() { + const navigate = useNavigate() + const [username, setUsername] = useState('admin') + const [password, setPassword] = useState('admin') + const [error, setError] = useState('') + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + try { + const res = await api.post<{ token: string }>('/api/v1/auth/login', { username, password }) + setToken(res.token) + navigate({ to: '/' }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Login failed') + } + } + + return ( +
+ + Sign in +
+ setUsername(e.target.value)} placeholder="Username" aria-label="Username" /> + setPassword(e.target.value)} placeholder="Password" aria-label="Password" /> + {error &&

{error}

} + +
+
+
+ ) +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..ba9851d --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "ignoreDeprecations": "6.0", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..d3c52ea --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo new file mode 100644 index 0000000..c05d901 --- /dev/null +++ b/frontend/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/app.tsx","./src/main.tsx","./src/components/layout/app-shell.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/input.tsx","./src/lib/api-client.ts","./src/lib/queryclient.ts","./src/lib/schemas.ts","./src/lib/utils.test.ts","./src/lib/utils.ts","./src/queries/index.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/login.tsx","./src/routes/_auth/certificates.tsx","./src/routes/_auth/groups.tsx","./src/routes/_auth/index.tsx","./src/routes/_auth/services.tsx","./src/routes/_auth/domains/index.tsx","./src/routes/_auth/domains/$domainid/dns.tsx"],"errors":true,"version":"6.0.3"} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..afac55f --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,25 @@ +import path from 'path' +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { TanStackRouterVite } from '@tanstack/router-vite-plugin' + +export default defineConfig({ + plugins: [ + TanStackRouterVite({ routesDirectory: './src/routes' }), + react(), + tailwindcss(), + ], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + proxy: { + '/api': 'http://localhost:8080', + '/health': 'http://localhost:8080', + '/ready': 'http://localhost:8080', + }, + }, +}) diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..28b0de0 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' +import path from 'path' + +export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +})