diff --git a/.agents/skills/tanstack-start-best-practices/SKILL.md b/.agents/skills/tanstack-start-best-practices/SKILL.md new file mode 100644 index 0000000..a1923cc --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/SKILL.md @@ -0,0 +1,110 @@ +--- +name: tanstack-start-best-practices +description: TanStack Start best practices for full-stack React applications. Server functions, middleware, SSR, authentication, and deployment patterns. Activate when building full-stack apps with TanStack Start. +--- + +# TanStack Start Best Practices + +Comprehensive guidelines for implementing TanStack Start patterns in full-stack React applications. These rules cover server functions, middleware, SSR, authentication, and deployment. + +## When to Apply + +- Creating server functions for data mutations +- Setting up middleware for auth/logging +- Configuring SSR and hydration +- Implementing authentication flows +- Handling errors across client/server boundary +- Organizing full-stack code +- Deploying to various platforms + +## Rule Categories by Priority + +| Priority | Category | Rules | Impact | +| -------- | ----------------- | ------- | --------------------------- | +| CRITICAL | Server Functions | 5 rules | Core data mutation patterns | +| CRITICAL | Security | 4 rules | Prevents vulnerabilities | +| HIGH | Middleware | 4 rules | Request/response handling | +| HIGH | Authentication | 4 rules | Secure user sessions | +| MEDIUM | API Routes | 1 rule | External endpoint patterns | +| MEDIUM | SSR | 6 rules | Server rendering patterns | +| MEDIUM | Error Handling | 3 rules | Graceful failure handling | +| MEDIUM | Environment | 1 rule | Configuration management | +| LOW | File Organization | 3 rules | Maintainable code structure | +| LOW | Deployment | 2 rules | Production readiness | + +## Quick Reference + +### Server Functions (Prefix: `sf-`) + +- `sf-create-server-fn` — Use createServerFn for server-side logic +- `sf-input-validation` — Always validate server function inputs +- `sf-method-selection` — Choose appropriate HTTP method +- `sf-error-handling` — Handle errors in server functions +- `sf-response-headers` — Customize response headers when needed + +### Security (Prefix: `sec-`) + +- `sec-validate-inputs` — Validate all user inputs with schemas +- `sec-auth-middleware` — Protect routes with auth middleware +- `sec-sensitive-data` — Keep secrets server-side only +- `sec-csrf-protection` — Implement CSRF protection for mutations + +### Middleware (Prefix: `mw-`) + +- `mw-request-middleware` — Use request middleware for cross-cutting concerns +- `mw-function-middleware` — Use function middleware for server functions +- `mw-context-flow` — Properly pass context through middleware +- `mw-composability` — Compose middleware effectively + +### Authentication (Prefix: `auth-`) + +- `auth-session-management` — Implement secure session handling +- `auth-route-protection` — Protect routes with beforeLoad +- `auth-server-functions` — Verify auth in server functions +- `auth-cookie-security` — Configure secure cookie settings + +### API Routes (Prefix: `api-`) + +- `api-routes` — Create API routes for external consumers + +### SSR (Prefix: `ssr-`) + +- `ssr-data-loading` — Load data appropriately for SSR +- `ssr-hydration-safety` — Prevent hydration mismatches +- `ssr-streaming` — Implement streaming SSR for faster TTFB +- `ssr-selective` — Apply selective SSR when beneficial +- `ssr-prerender` — Configure static prerendering and ISR + +### Environment (Prefix: `env-`) + +- `env-functions` — Use environment functions for configuration + +### Error Handling (Prefix: `err-`) + +- `err-server-errors` — Handle server function errors +- `err-redirects` — Use redirects appropriately +- `err-not-found` — Handle not-found scenarios + +### File Organization (Prefix: `file-`) + +- `file-separation` — Separate server and client code +- `file-functions-file` — Use .functions.ts pattern +- `file-shared-validation` — Share validation schemas + +### Deployment (Prefix: `deploy-`) + +- `deploy-env-config` — Configure environment variables +- `deploy-adapters` — Choose appropriate deployment adapter + +## How to Use + +Each rule file in the `rules/` directory contains: + +1. **Explanation** — Why this pattern matters +2. **Bad Example** — Anti-pattern to avoid +3. **Good Example** — Recommended implementation +4. **Context** — When to apply or skip this rule + +## Full Reference + +See individual rule files in `rules/` directory for detailed guidance and code examples. diff --git a/.agents/skills/tanstack-start-best-practices/rules/api-routes.md b/.agents/skills/tanstack-start-best-practices/rules/api-routes.md new file mode 100644 index 0000000..000656e --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/api-routes.md @@ -0,0 +1,236 @@ +# api-routes: Create Server Routes for External Consumers + +## Priority: MEDIUM + +## Explanation + +While server functions are ideal for internal RPC, server routes provide traditional REST endpoints for external consumers, webhooks, and integrations. Use server routes when you need standard HTTP semantics, custom response formats, or third-party compatibility. + +## Bad Example + +```tsx +// Using server functions for webhook endpoints +export const stripeWebhook = createServerFn({ method: "POST" }).handler(async ({ request }) => { + // Server functions aren't designed for raw request handling + // No easy access to raw body for signature verification + // Response format is JSON by default +}); + +// Or exposing internal functions to external consumers +export const getUsers = createServerFn().handler(async () => { + return db.users.findMany(); +}); +// No versioning, no standard REST semantics +``` + +## Good Example: Basic Server Route + +```tsx +// routes/api/users.ts +import { createFileRoute } from "@tanstack/react-router"; +import { json } from "@tanstack/react-start"; + +export const Route = createFileRoute("/api/users")({ + server: { + handlers: { + GET: async ({ request }) => { + const users = await db.users.findMany({ + select: { id: true, name: true, email: true }, + }); + + return json(users, { + headers: { + "Cache-Control": "public, max-age=60", + }, + }); + }, + + POST: async ({ request }) => { + const body = await request.json(); + + // Validate input + const parsed = createUserSchema.safeParse(body); + if (!parsed.success) { + return json({ error: parsed.error.flatten() }, { status: 400 }); + } + + const user = await db.users.create({ data: parsed.data }); + return json(user, { status: 201 }); + }, + }, + }, +}); +``` + +## Good Example: Webhook Handler + +```tsx +// routes/api/webhooks/stripe.ts +import { createFileRoute } from "@tanstack/react-router"; +import Stripe from "stripe"; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); + +export const Route = createFileRoute("/api/webhooks/stripe")({ + server: { + handlers: { + POST: async ({ request }) => { + const signature = request.headers.get("stripe-signature"); + if (!signature) { + return new Response("Missing signature", { status: 400 }); + } + + // Get raw body for signature verification + const rawBody = await request.text(); + + let event: Stripe.Event; + try { + event = stripe.webhooks.constructEvent( + rawBody, + signature, + process.env.STRIPE_WEBHOOK_SECRET!, + ); + } catch (err) { + console.error("Webhook signature verification failed:", err); + return new Response("Invalid signature", { status: 400 }); + } + + // Handle the event + switch (event.type) { + case "checkout.session.completed": + await handleCheckoutComplete(event.data.object); + break; + case "customer.subscription.updated": + await handleSubscriptionUpdate(event.data.object); + break; + default: + console.log(`Unhandled event type: ${event.type}`); + } + + return new Response("OK", { status: 200 }); + }, + }, + }, +}); +``` + +## Good Example: RESTful Resource with Dynamic Params + +```tsx +// routes/api/posts/$postId.ts +import { createFileRoute } from "@tanstack/react-router"; +import { json } from "@tanstack/react-start"; + +export const Route = createFileRoute("/api/posts/$postId")({ + server: { + handlers: { + GET: async ({ params }) => { + const post = await db.posts.findUnique({ + where: { id: params.postId }, + }); + + if (!post) { + return json({ error: "Post not found" }, { status: 404 }); + } + + return json(post); + }, + + PUT: async ({ request, params }) => { + const body = await request.json(); + const parsed = updatePostSchema.safeParse(body); + + if (!parsed.success) { + return json({ error: parsed.error.flatten() }, { status: 400 }); + } + + const post = await db.posts.update({ + where: { id: params.postId }, + data: parsed.data, + }); + + return json(post); + }, + + DELETE: async ({ params }) => { + await db.posts.delete({ where: { id: params.postId } }); + return new Response(null, { status: 204 }); + }, + }, + }, +}); +``` + +## Good Example: With Route-Level Middleware + +```tsx +// routes/api/protected/data.ts +import { createFileRoute } from "@tanstack/react-router"; +import { json } from "@tanstack/react-start"; +import { apiKeyMiddleware } from "@/lib/middleware"; + +export const Route = createFileRoute("/api/protected/data")({ + server: { + // Middleware applies to all handlers in this route + middleware: [apiKeyMiddleware], + handlers: { + GET: async ({ request, context }) => { + // context.client available from middleware + const data = await fetchDataForClient(context.client.id); + return json(data); + }, + }, + }, +}); +``` + +## Good Example: Using createHandlers for Handler-Specific Middleware + +```tsx +// routes/api/admin/users.ts +import { createFileRoute } from "@tanstack/react-router"; +import { json } from "@tanstack/react-start"; + +export const Route = createFileRoute("/api/admin/users")({ + server: { + middleware: [authMiddleware], // All handlers require auth + handlers: (createHandlers) => ({ + GET: createHandlers.GET(async ({ context }) => { + const users = await db.users.findMany(); + return json(users); + }), + + // DELETE requires additional admin middleware + DELETE: createHandlers.DELETE({ + middleware: [adminOnlyMiddleware], + handler: async ({ request, context }) => { + const { userId } = await request.json(); + await db.users.delete({ where: { id: userId } }); + return json({ deleted: true }); + }, + }), + }), + }, +}); +``` + +## Server Functions vs Server Routes + +| Feature | Server Functions | Server Routes | +| ------------------ | ---------------- | ------------------ | +| Primary use | Internal RPC | External consumers | +| Type safety | Full end-to-end | Manual | +| Response format | JSON (automatic) | Any (manual) | +| Raw request access | Limited | Full | +| URL structure | Auto-generated | Explicit paths | +| Webhooks | Not ideal | Designed for | + +## Context + +- Server routes use `createFileRoute` with a `server.handlers` property +- Support all HTTP methods: GET, POST, PUT, PATCH, DELETE, etc. +- Use `json()` helper for JSON responses +- Return `Response` objects for custom formats +- Handler receives `{ request, params }` object +- Ideal for: webhooks, public APIs, file downloads, third-party integrations +- Consider versioning: `/api/v1/users` for public APIs diff --git a/.agents/skills/tanstack-start-best-practices/rules/auth-route-protection.md b/.agents/skills/tanstack-start-best-practices/rules/auth-route-protection.md new file mode 100644 index 0000000..c9570b3 --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/auth-route-protection.md @@ -0,0 +1,188 @@ +# auth-route-protection: Protect Routes with beforeLoad + +## Priority: HIGH + +## Explanation + +Use `beforeLoad` in route definitions to check authentication before the route loads. This prevents unauthorized access, redirects to login, and can extend context with user data for child routes. + +## Bad Example + +```tsx +// Checking auth in component - too late, data may have loaded +function DashboardPage() { + const user = useAuth(); + + useEffect(() => { + if (!user) { + navigate({ to: "/login" }); // Redirect after render + } + }, [user]); + + if (!user) return null; // Flash of content possible + + return ; +} + +// No protection on route +export const Route = createFileRoute("/dashboard")({ + loader: async () => { + // Fetches sensitive data even for unauthenticated users + return await fetchDashboardData(); + }, + component: DashboardPage, +}); +``` + +## Good Example: Route-Level Protection + +```tsx +// routes/_authenticated.tsx - Layout route for protected area +import { createFileRoute, redirect, Outlet } from "@tanstack/react-router"; +import { getSessionData } from "@/lib/session.server"; + +export const Route = createFileRoute("/_authenticated")({ + beforeLoad: async ({ location }) => { + const session = await getSessionData(); + + if (!session) { + throw redirect({ + to: "/login", + search: { + redirect: location.href, + }, + }); + } + + // Extend context with user for all child routes + return { + user: session, + }; + }, + component: AuthenticatedLayout, +}); + +function AuthenticatedLayout() { + return ( +
+ +
+ {/* Child routes render here */} +
+
+ ); +} + +// routes/_authenticated/dashboard.tsx +// This route is automatically protected by parent +export const Route = createFileRoute("/_authenticated/dashboard")({ + loader: async ({ context }) => { + // context.user is guaranteed to exist + return await fetchDashboardData(context.user.id); + }, + component: DashboardPage, +}); + +function DashboardPage() { + const data = Route.useLoaderData(); + const { user } = Route.useRouteContext(); + + return ; +} +``` + +## Good Example: Role-Based Access + +```tsx +// routes/_admin.tsx +export const Route = createFileRoute("/_admin")({ + beforeLoad: async ({ context }) => { + // context.user comes from parent _authenticated route + if (context.user.role !== "admin") { + throw redirect({ to: "/unauthorized" }); + } + }, + component: AdminLayout, +}); + +// File structure: +// routes/ +// _authenticated.tsx # Requires login +// _authenticated/ +// dashboard.tsx # /dashboard - any authenticated user +// settings.tsx # /settings - any authenticated user +// _admin.tsx # Admin layout +// _admin/ +// users.tsx # /users - admin only +// analytics.tsx # /analytics - admin only +``` + +## Good Example: Preserving Redirect URL + +```tsx +// routes/login.tsx +import { z } from "zod"; + +export const Route = createFileRoute("/login")({ + validateSearch: z.object({ + redirect: z.string().optional(), + }), + component: LoginPage, +}); + +function LoginPage() { + const { redirect } = Route.useSearch(); + const loginMutation = useMutation({ + mutationFn: login, + onSuccess: () => { + // Redirect to original destination or default + navigate({ to: redirect ?? "/dashboard" }); + }, + }); + + return ; +} + +// In protected routes +beforeLoad: async ({ location }) => { + if (!session) { + throw redirect({ + to: "/login", + search: { redirect: location.href }, + }); + } +}; +``` + +## Good Example: Conditional Content Based on Auth + +```tsx +// Public route with different content for logged-in users +export const Route = createFileRoute("/")({ + beforeLoad: async () => { + const session = await getSessionData(); + return { user: session?.user ?? null }; + }, + component: HomePage, +}); + +function HomePage() { + const { user } = Route.useRouteContext(); + + return ( +
+ + {user ? : } +
+ ); +} +``` + +## Context + +- `beforeLoad` runs before route loading begins +- Throwing `redirect()` prevents route from loading +- Context from `beforeLoad` flows to loader and component +- Child routes inherit parent's `beforeLoad` protection +- Use pathless layout routes (`_authenticated.tsx`) for grouped protection +- Store redirect URL in search params for post-login navigation diff --git a/.agents/skills/tanstack-start-best-practices/rules/auth-session-management.md b/.agents/skills/tanstack-start-best-practices/rules/auth-session-management.md new file mode 100644 index 0000000..56d75d0 --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/auth-session-management.md @@ -0,0 +1,189 @@ +# auth-session-management: Implement Secure Session Handling + +## Priority: HIGH + +## Explanation + +Sessions maintain user authentication state across requests. Use HTTP-only cookies with secure settings to prevent XSS and CSRF attacks. Never store sensitive data in client-accessible storage. + +## Bad Example + +```tsx +// Storing auth in localStorage - vulnerable to XSS +function login(credentials: Credentials) { + const token = await authenticate(credentials); + localStorage.setItem("authToken", token); // XSS can steal this +} + +// Non-HTTP-only cookie - JavaScript accessible +export const setSession = createServerFn({ method: "POST" }).handler(async ({ data }) => { + setResponseHeader("Set-Cookie", `session=${data.token}`); // Not secure +}); +``` + +## Good Example: Secure Session Cookie + +```tsx +// lib/session.server.ts +import { useSession } from "@tanstack/react-start/server"; + +// Configure session with secure defaults +export function getSession() { + return useSession({ + password: process.env.SESSION_SECRET!, // At least 32 characters + cookie: { + name: "__session", + httpOnly: true, // Not accessible via JavaScript + secure: process.env.NODE_ENV === "production", // HTTPS only in prod + sameSite: "lax", // CSRF protection + maxAge: 60 * 60 * 24 * 7, // 7 days + }, + }); +} + +// Usage in server function +export const login = createServerFn({ method: "POST" }) + .validator(loginSchema) + .handler(async ({ data }) => { + const session = await getSession(); + + // Verify credentials + const user = await verifyCredentials(data.email, data.password); + if (!user) { + throw new Error("Invalid credentials"); + } + + // Store only essential data in session + await session.update({ + userId: user.id, + email: user.email, + createdAt: Date.now(), + }); + + return { success: true }; + }); +``` + +## Good Example: Full Authentication Flow + +```tsx +// lib/auth.functions.ts +import { createServerFn } from "@tanstack/react-start"; +import { redirect } from "@tanstack/react-router"; +import { getSession } from "./session.server"; +import { hashPassword, verifyPassword } from "./password.server"; + +// Login +export const login = createServerFn({ method: "POST" }) + .validator( + z.object({ + email: z.string().email(), + password: z.string().min(1), + }), + ) + .handler(async ({ data }) => { + const user = await db.users.findUnique({ + where: { email: data.email }, + }); + + if (!user || !(await verifyPassword(data.password, user.passwordHash))) { + throw new Error("Invalid email or password"); + } + + const session = await getSession(); + await session.update({ + userId: user.id, + email: user.email, + }); + + throw redirect({ to: "/dashboard" }); + }); + +// Logout +export const logout = createServerFn({ method: "POST" }).handler(async () => { + const session = await getSession(); + await session.clear(); + throw redirect({ to: "/" }); +}); + +// Get current user +export const getCurrentUser = createServerFn().handler(async () => { + const session = await getSession(); + const data = await session.data; + + if (!data?.userId) { + return null; + } + + const user = await db.users.findUnique({ + where: { id: data.userId }, + select: { + id: true, + email: true, + name: true, + avatar: true, + // Don't include passwordHash! + }, + }); + + return user; +}); +``` + +## Good Example: Session with Role-Based Access + +```tsx +// lib/session.server.ts +interface SessionData { + userId: string; + email: string; + role: "user" | "admin"; + createdAt: number; +} + +export async function getSessionData(): Promise { + const session = await getSession(); + const data = await session.data; + + if (!data?.userId) return null; + + // Validate session age + const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days + if (Date.now() - data.createdAt > maxAge) { + await session.clear(); + return null; + } + + return data as SessionData; +} + +// Middleware for admin-only routes +export const requireAdmin = createMiddleware().server(async ({ next }) => { + const session = await getSessionData(); + + if (!session || session.role !== "admin") { + throw redirect({ to: "/unauthorized" }); + } + + return next({ context: { session } }); +}); +``` + +## Session Security Checklist + +| Setting | Value | Purpose | +| ---------- | --------------------- | ---------------------------------- | +| `httpOnly` | `true` | Prevents XSS from accessing cookie | +| `secure` | `true` in prod | Requires HTTPS | +| `sameSite` | `'lax'` or `'strict'` | CSRF protection | +| `maxAge` | Application-specific | Session duration | +| `password` | 32+ random chars | Encryption key | + +## Context + +- Always use HTTP-only cookies for session tokens +- Generate `SESSION_SECRET` with `openssl rand -base64 32` +- Store minimal data in session - fetch user details on demand +- Implement session rotation on privilege changes +- Consider session invalidation on password change +- Use `sameSite: 'strict'` for highest CSRF protection diff --git a/.agents/skills/tanstack-start-best-practices/rules/deploy-adapters.md b/.agents/skills/tanstack-start-best-practices/rules/deploy-adapters.md new file mode 100644 index 0000000..83682de --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/deploy-adapters.md @@ -0,0 +1,197 @@ +# deploy-adapters: Choose Appropriate Deployment Adapter + +## Priority: LOW + +## Explanation + +TanStack Start uses deployment adapters to target different hosting platforms. Each adapter optimizes the build output for its platform's runtime, edge functions, and static hosting capabilities. + +## Bad Example + +```tsx +// Not configuring adapter - using defaults may not match your host +// app.config.ts +export default defineConfig({ + // No adapter specified + // May not work correctly on your deployment platform +}); + +// Or using wrong adapter for platform +export default defineConfig({ + server: { + preset: "node-server", // But deploying to Vercel Edge + }, +}); +``` + +## Good Example: Vercel Deployment + +```tsx +// app.config.ts +import { defineConfig } from '@tanstack/react-start/config' + +export default defineConfig({ + server: { + preset: 'vercel', + // Vercel-specific options + }, +}) + +// vercel.json (optional, for customization) +{ + "framework": null, + "buildCommand": "npm run build", + "outputDirectory": ".output" +} +``` + +## Good Example: Cloudflare Pages + +```tsx +// app.config.ts +import { defineConfig } from "@tanstack/react-start/config"; + +export default defineConfig({ + server: { + preset: "cloudflare-pages", + }, +}); + +// wrangler.toml +name = "my-tanstack-app"; +compatibility_date = "2024-01-01"; +pages_build_output_dir = ".output/public"; + +// For Cloudflare Workers (full control) +export default defineConfig({ + server: { + preset: "cloudflare", + }, +}); +``` + +## Good Example: Netlify + +```tsx +// app.config.ts +import { defineConfig } from "@tanstack/react-start/config"; + +export default // netlify.toml +defineConfig({ + server: { + preset: "netlify", + }, +})[build]; +command = "npm run build"; +publish = ".output/public"[functions]; +directory = ".output/server"; +``` + +## Good Example: Node.js Server + +```tsx +// app.config.ts +import { defineConfig } from '@tanstack/react-start/config' + +export default defineConfig({ + server: { + preset: 'node-server', + // Optional: customize port + }, +}) + +// Dockerfile +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --only=production +COPY .output .output +EXPOSE 3000 +CMD ["node", ".output/server/index.mjs"] + +// Or run directly +// node .output/server/index.mjs +``` + +## Good Example: Static Export (SPA) + +```tsx +// app.config.ts +import { defineConfig } from "@tanstack/react-start/config"; + +export default defineConfig({ + server: { + preset: "static", + prerender: { + routes: ["/"], + crawlLinks: true, + }, + }, +}); + +// Output: .output/public (static files only) +// Host anywhere: GitHub Pages, S3, any static host +``` + +## Good Example: AWS Lambda + +```tsx +// app.config.ts +import { defineConfig } from '@tanstack/react-start/config' + +export default defineConfig({ + server: { + preset: 'aws-lambda', + }, +}) + +// Deploy with SST, Serverless Framework, or AWS CDK +// serverless.yml example: +service: my-tanstack-app +provider: + name: aws + runtime: nodejs20.x +functions: + app: + handler: .output/server/index.handler + events: + - http: ANY / + - http: ANY /{proxy+} +``` + +## Good Example: Bun Runtime + +```tsx +// app.config.ts +import { defineConfig } from "@tanstack/react-start/config"; + +export default defineConfig({ + server: { + preset: "bun", + }, +}); + +// Run with: bun .output/server/index.mjs +``` + +## Adapter Comparison + +| Adapter | Runtime | Edge | Static | Best For | +| ------------------ | --------- | ---- | ------ | ---------------------- | +| `vercel` | Node/Edge | Yes | Yes | Vercel hosting | +| `cloudflare-pages` | Workers | Yes | Yes | Cloudflare Pages | +| `cloudflare` | Workers | Yes | No | Cloudflare Workers | +| `netlify` | Node | Yes | Yes | Netlify hosting | +| `node-server` | Node | No | No | Docker, VPS, self-host | +| `static` | None | No | Yes | Any static host | +| `aws-lambda` | Node | No | No | AWS serverless | +| `bun` | Bun | No | No | Bun runtime | + +## Context + +- Adapters transform output for target platform +- Edge adapters have API limitations (no file system, etc.) +- Static preset requires all routes to be prerenderable +- Test locally with `npm run build && npm run preview` +- Check platform docs for runtime-specific constraints +- Some platforms auto-detect TanStack Start (no adapter needed) diff --git a/.agents/skills/tanstack-start-best-practices/rules/env-functions.md b/.agents/skills/tanstack-start-best-practices/rules/env-functions.md new file mode 100644 index 0000000..05f4e40 --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/env-functions.md @@ -0,0 +1,207 @@ +# env-functions: Use Environment Functions for Configuration + +## Priority: MEDIUM + +## Explanation + +Environment functions provide type-safe access to environment variables on the server. They ensure secrets stay server-side, provide validation, and enable different configurations per environment (development, staging, production). + +## Bad Example + +```tsx +// Accessing env vars directly - no validation, potential leaks +export const getApiData = createServerFn().handler(async () => { + // No validation - may be undefined + const apiKey = process.env.API_KEY; + + // Accidentally exposed in error messages + if (!apiKey) { + throw new Error(`Missing API_KEY: ${process.env}`); + } + + return fetch(url, { headers: { Authorization: apiKey } }); +}); + +// Or importing env in shared files +// lib/config.ts +export const config = { + apiKey: process.env.API_KEY, // Bundled into client! + dbUrl: process.env.DATABASE_URL, +}; +``` + +## Good Example: Validated Environment Configuration + +```tsx +// lib/env.server.ts +import { z } from "zod"; + +const envSchema = z.object({ + // Required + DATABASE_URL: z.string().url(), + SESSION_SECRET: z.string().min(32), + + // API Keys + STRIPE_SECRET_KEY: z.string().startsWith("sk_"), + STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_"), + + // Optional with defaults + NODE_ENV: z.enum(["development", "staging", "production"]).default("development"), + LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), + + // Optional + SENTRY_DSN: z.string().url().optional(), +}); + +export type Env = z.infer; + +function validateEnv(): Env { + const parsed = envSchema.safeParse(process.env); + + if (!parsed.success) { + console.error("Invalid environment variables:"); + console.error(parsed.error.flatten().fieldErrors); + throw new Error("Invalid environment configuration"); + } + + return parsed.data; +} + +// Validate once at startup +export const env = validateEnv(); + +// Usage in server functions +export const getPaymentIntent = createServerFn({ method: "POST" }).handler(async () => { + const stripe = new Stripe(env.STRIPE_SECRET_KEY); + // Type-safe, validated access +}); +``` + +## Good Example: Public vs Private Config + +```tsx +// lib/env.server.ts - Server only (secrets) +export const serverEnv = { + databaseUrl: process.env.DATABASE_URL!, + sessionSecret: process.env.SESSION_SECRET!, + stripeSecretKey: process.env.STRIPE_SECRET_KEY!, +}; + +// lib/env.ts - Public config (safe for client) +export const publicEnv = { + appUrl: process.env.VITE_APP_URL ?? "http://localhost:3000", + stripePublicKey: process.env.VITE_STRIPE_PUBLIC_KEY!, + sentryDsn: process.env.VITE_SENTRY_DSN, +}; + +// Vite exposes VITE_ prefixed vars to client +// Non-prefixed vars are server-only +``` + +## Good Example: Environment-Specific Behavior + +```tsx +// lib/env.server.ts +export const env = validateEnv(); + +export const isDevelopment = env.NODE_ENV === "development"; +export const isProduction = env.NODE_ENV === "production"; +export const isStaging = env.NODE_ENV === "staging"; + +// lib/logger.server.ts +import { env, isDevelopment } from "./env.server"; + +export function log(level: string, message: string, data?: unknown) { + if (isDevelopment) { + console.log(`[${level}]`, message, data); + return; + } + + // Production: send to logging service + if (env.SENTRY_DSN) { + // Send to Sentry + } +} + +// Server function with environment checks +export const debugInfo = createServerFn().handler(async () => { + if (isProduction) { + throw new Error("Debug endpoint not available in production"); + } + + return { + nodeVersion: process.version, + env: env.NODE_ENV, + }; +}); +``` + +## Good Example: Feature Flags via Environment + +```tsx +// lib/features.server.ts +import { env } from "./env.server"; + +export const features = { + newCheckout: env.FEATURE_NEW_CHECKOUT === "true", + betaDashboard: env.FEATURE_BETA_DASHBOARD === "true", + aiAssistant: env.FEATURE_AI_ASSISTANT === "true", +}; + +// Usage in server functions +export const getCheckoutUrl = createServerFn().handler(async () => { + if (features.newCheckout) { + return "/checkout/v2"; + } + return "/checkout"; +}); + +// Usage in loaders +export const Route = createFileRoute("/dashboard")({ + loader: async () => { + return { + showBetaFeatures: features.betaDashboard, + }; + }, +}); +``` + +## Good Example: Type-Safe env.d.ts + +```tsx +// env.d.ts - TypeScript declarations for env vars +declare namespace NodeJS { + interface ProcessEnv { + // Required + DATABASE_URL: string; + SESSION_SECRET: string; + + // Optional + NODE_ENV?: "development" | "staging" | "production"; + SENTRY_DSN?: string; + + // Vite public vars + VITE_APP_URL?: string; + VITE_STRIPE_PUBLIC_KEY: string; + } +} +``` + +## Environment Variable Checklist + +| Variable | Prefix | Accessible On | +| ------------------------ | ------- | --------------- | +| `DATABASE_URL` | None | Server only | +| `SESSION_SECRET` | None | Server only | +| `STRIPE_SECRET_KEY` | None | Server only | +| `VITE_APP_URL` | `VITE_` | Server + Client | +| `VITE_STRIPE_PUBLIC_KEY` | `VITE_` | Server + Client | + +## Context + +- Never import `.server.ts` files in client code +- Use `VITE_` prefix for client-accessible variables +- Validate at startup to fail fast on misconfiguration +- Use Zod or similar for runtime validation +- Keep secrets out of error messages and logs +- Consider using `.env.local` for local overrides (gitignored) diff --git a/.agents/skills/tanstack-start-best-practices/rules/err-server-errors.md b/.agents/skills/tanstack-start-best-practices/rules/err-server-errors.md new file mode 100644 index 0000000..02d7421 --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/err-server-errors.md @@ -0,0 +1,189 @@ +# err-server-errors: Handle Server Function Errors + +## Priority: MEDIUM + +## Explanation + +Server function errors cross the network boundary. Handle them gracefully with appropriate error types, status codes, and user-friendly messages. Avoid exposing internal details in production. + +## Bad Example + +```tsx +// Throwing raw errors - exposes internals +export const createUser = createServerFn({ method: "POST" }) + .validator(createUserSchema) + .handler(async ({ data }) => { + const user = await db.users.create({ data }); // May throw DB error + return user; + // Prisma error with stack trace sent to client + }); + +// Generic error handling - no useful info for client +export const getPost = createServerFn().handler(async ({ data }) => { + try { + return await fetchPost(data.id); + } catch (e) { + throw new Error("Something went wrong"); // Too vague + } +}); +``` + +## Good Example: Structured Error Handling + +```tsx +// lib/errors.ts +export class AppError extends Error { + constructor( + message: string, + public code: string, + public status: number = 400, + ) { + super(message); + this.name = "AppError"; + } +} + +export class NotFoundError extends AppError { + constructor(resource: string) { + super(`${resource} not found`, "NOT_FOUND", 404); + } +} + +export class UnauthorizedError extends AppError { + constructor(message = "Unauthorized") { + super(message, "UNAUTHORIZED", 401); + } +} + +export class ValidationError extends AppError { + constructor( + message: string, + public fields?: Record, + ) { + super(message, "VALIDATION_ERROR", 400); + } +} +``` + +## Good Example: Server Function with Error Handling + +```tsx +import { createServerFn, notFound } from "@tanstack/react-start"; +import { setResponseStatus } from "@tanstack/react-start/server"; + +export const getPost = createServerFn() + .validator(z.object({ id: z.string() })) + .handler(async ({ data }) => { + const post = await db.posts.findUnique({ + where: { id: data.id }, + }); + + if (!post) { + // Use built-in notFound for 404s + throw notFound(); + } + + return post; + }); + +export const createPost = createServerFn({ method: "POST" }) + .validator(createPostSchema) + .handler(async ({ data }) => { + try { + const post = await db.posts.create({ data }); + return post; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === "P2002") { + // Unique constraint violation + setResponseStatus(409); + throw new AppError("A post with this title already exists", "DUPLICATE", 409); + } + } + + // Log full error server-side + console.error("Failed to create post:", error); + + // Return sanitized error to client + setResponseStatus(500); + throw new AppError("Failed to create post", "INTERNAL_ERROR", 500); + } + }); +``` + +## Good Example: Client-Side Error Handling + +```tsx +function CreatePostForm() { + const [error, setError] = useState(null); + + const createMutation = useMutation({ + mutationFn: createPost, + onError: (error) => { + if (error instanceof AppError) { + setError(error.message); + } else if (error instanceof ValidationError) { + // Handle field-specific errors + Object.entries(error.fields ?? {}).forEach(([field, message]) => { + form.setError(field, { message }); + }); + } else { + setError("An unexpected error occurred"); + } + }, + onSuccess: (post) => { + navigate({ to: "/posts/$postId", params: { postId: post.id } }); + }, + }); + + return ( +
+ {error && {error}} + {/* form fields */} +
+ ); +} +``` + +## Good Example: Using Redirects for Auth Errors + +```tsx +export const updateProfile = createServerFn({ method: "POST" }) + .validator(updateProfileSchema) + .handler(async ({ data }) => { + const session = await getSessionData(); + + if (!session) { + // Redirect to login for auth errors + throw redirect({ + to: "/login", + search: { redirect: "/settings" }, + }); + } + + return await db.users.update({ + where: { id: session.userId }, + data, + }); + }); +``` + +## Error Response Best Practices + +| Scenario | HTTP Status | Response | +| -------------------- | ----------- | ---------------------------- | +| Validation failed | 400 | Field-specific errors | +| Not authenticated | 401 | Redirect to login | +| Not authorized | 403 | Generic forbidden message | +| Resource not found | 404 | Use `notFound()` | +| Conflict (duplicate) | 409 | Specific conflict message | +| Server error | 500 | Generic message, log details | + +## Context + +- Use `notFound()` for 404 errors - integrates with router +- Use `redirect()` for auth-related errors +- Set status codes with `setResponseStatus()` +- Log full errors server-side, sanitize for client +- Create custom error classes for consistent handling +- Validation errors from `.validator()` are automatic diff --git a/.agents/skills/tanstack-start-best-practices/rules/file-separation.md b/.agents/skills/tanstack-start-best-practices/rules/file-separation.md new file mode 100644 index 0000000..a0a880c --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/file-separation.md @@ -0,0 +1,151 @@ +# file-separation: Separate Server and Client Code + +## Priority: LOW + +## Explanation + +Organize code by execution context to prevent server code from accidentally bundling into client builds. Use `.server.ts` for server-only code, `.functions.ts` for server function definitions, and standard `.ts` for shared code. + +## Bad Example + +```tsx +// lib/posts.ts - Mixed server and client code +import { db } from "./db"; // Database - server only +import { formatDate } from "./utils"; // Utility - shared + +export async function getPosts() { + // This uses db, so it's server-only + // But file might be imported on client + return db.posts.findMany(); +} + +export function formatPostDate(date: Date) { + // This could run anywhere + return formatDate(date); +} + +// routes/posts.tsx +import { getPosts, formatPostDate } from "@/lib/posts"; +// Importing getPosts pulls db into client bundle (error or bloat) +``` + +## Good Example: Clear Separation + +``` +lib/ +├── posts.ts # Shared types and utilities +├── posts.server.ts # Server-only database logic +├── posts.functions.ts # Server function definitions +└── schemas/ + └── post.ts # Shared validation schemas +``` + +```tsx +// lib/posts.ts - Shared (safe to import anywhere) +export interface Post { + id: string; + title: string; + content: string; + createdAt: Date; +} + +export function formatPostDate(date: Date): string { + return new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + }).format(date); +} + +// lib/posts.server.ts - Server only (never import on client) +import { db } from "./db"; +import type { Post } from "./posts"; + +export async function getPostsFromDb(): Promise { + return db.posts.findMany({ + orderBy: { createdAt: "desc" }, + }); +} + +export async function createPostInDb(data: CreatePostInput): Promise { + return db.posts.create({ data }); +} + +// lib/posts.functions.ts - Server functions (safe to import anywhere) +import { createServerFn } from "@tanstack/react-start"; +import { getPostsFromDb, createPostInDb } from "./posts.server"; +import { createPostSchema } from "./schemas/post"; + +export const getPosts = createServerFn().handler(async () => { + return await getPostsFromDb(); +}); + +export const createPost = createServerFn({ method: "POST" }) + .validator(createPostSchema) + .handler(async ({ data }) => { + return await createPostInDb(data); + }); +``` + +## Good Example: Using in Components + +```tsx +// components/PostList.tsx +import { getPosts } from "@/lib/posts.functions"; // Safe - RPC stub on client +import { formatPostDate } from "@/lib/posts"; // Safe - shared utility +import type { Post } from "@/lib/posts"; // Safe - type only + +function PostList() { + const postsQuery = useQuery({ + queryKey: ["posts"], + queryFn: () => getPosts(), // Calls server function + }); + + return ( +
    + {postsQuery.data?.map((post) => ( +
  • + {post.title} + {formatPostDate(post.createdAt)} +
  • + ))} +
+ ); +} +``` + +## File Convention Summary + +| Suffix | Purpose | Safe to Import on Client | +| --------------- | ------------------------------- | ------------------------ | +| `.ts` | Shared utilities, types | Yes | +| `.server.ts` | Server-only logic (db, secrets) | No | +| `.functions.ts` | Server function wrappers | Yes | +| `.client.ts` | Client-only code | Yes (client only) | + +## Good Example: Environment Variables + +```tsx +// lib/config.server.ts - Server secrets +export const config = { + databaseUrl: process.env.DATABASE_URL!, + sessionSecret: process.env.SESSION_SECRET!, + stripeSecretKey: process.env.STRIPE_SECRET_KEY!, +}; + +// lib/config.ts - Public config (safe for client) +export const publicConfig = { + appName: "My App", + apiUrl: process.env.NEXT_PUBLIC_API_URL, + stripePublicKey: process.env.NEXT_PUBLIC_STRIPE_KEY, +}; + +// Never import config.server.ts on client +``` + +## Context + +- `.server.ts` files should never be directly imported in client code +- Server functions in `.functions.ts` are safe - build replaces with RPC +- Types from `.server.ts` are safe if using `import type` +- TanStack Start's build process validates proper separation +- This pattern enables tree-shaking and smaller client bundles +- Use consistent naming convention across your team diff --git a/.agents/skills/tanstack-start-best-practices/rules/mw-request-middleware.md b/.agents/skills/tanstack-start-best-practices/rules/mw-request-middleware.md new file mode 100644 index 0000000..c31622a --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/mw-request-middleware.md @@ -0,0 +1,157 @@ +# mw-request-middleware: Use Request Middleware for Cross-Cutting Concerns + +## Priority: HIGH + +## Explanation + +Request middleware runs before every server request (routes, SSR, server functions). Use it for authentication, logging, rate limiting, and other cross-cutting concerns that apply globally. + +## Bad Example + +```tsx +// Duplicating auth logic in every server function +export const getProfile = createServerFn().handler(async () => { + const session = await getSession(); + if (!session) throw new Error("Unauthorized"); + // ... rest of handler +}); + +export const updateProfile = createServerFn({ method: "POST" }).handler(async ({ data }) => { + const session = await getSession(); + if (!session) throw new Error("Unauthorized"); + // ... rest of handler +}); + +export const deleteAccount = createServerFn({ method: "POST" }).handler(async () => { + const session = await getSession(); + if (!session) throw new Error("Unauthorized"); + // ... rest of handler +}); +``` + +## Good Example: Authentication Middleware + +```tsx +// lib/middleware/auth.ts +import { createMiddleware } from "@tanstack/react-start"; +import { getSession } from "./session.server"; + +export const authMiddleware = createMiddleware().server(async ({ next }) => { + const session = await getSession(); + + // Pass session to downstream handlers via context + return next({ + context: { + session, + user: session?.user ?? null, + }, + }); +}); + +// lib/middleware/requireAuth.ts +export const requireAuthMiddleware = createMiddleware() + .middleware([authMiddleware]) // Depends on auth middleware + .server(async ({ next, context }) => { + if (!context.user) { + throw redirect({ to: "/login" }); + } + + return next({ + context: { + user: context.user, // Now guaranteed to exist + }, + }); + }); +``` + +## Good Example: Logging Middleware + +```tsx +// lib/middleware/logging.ts +export const loggingMiddleware = createMiddleware().server(async ({ next, request }) => { + const start = Date.now(); + const requestId = crypto.randomUUID(); + + console.log(`[${requestId}] ${request.method} ${request.url}`); + + try { + const result = await next({ + context: { requestId }, + }); + + console.log(`[${requestId}] Completed in ${Date.now() - start}ms`); + return result; + } catch (error) { + console.error(`[${requestId}] Error:`, error); + throw error; + } +}); +``` + +## Good Example: Global Middleware Configuration + +```tsx +// app/start.ts +import { createStart } from "@tanstack/react-start/server"; +import { loggingMiddleware } from "./middleware/logging"; +import { authMiddleware } from "./middleware/auth"; + +export default createStart({ + // Request middleware runs for all requests + requestMiddleware: [loggingMiddleware, authMiddleware], +}); +``` + +## Good Example: Rate Limiting Middleware + +```tsx +// lib/middleware/rateLimit.ts +import { createMiddleware } from "@tanstack/react-start"; + +const rateLimitStore = new Map(); + +export const rateLimitMiddleware = createMiddleware().server(async ({ next, request }) => { + const ip = request.headers.get("x-forwarded-for") ?? "unknown"; + const now = Date.now(); + const windowMs = 60 * 1000; // 1 minute + const maxRequests = 100; + + let record = rateLimitStore.get(ip); + + if (!record || record.resetAt < now) { + record = { count: 0, resetAt: now + windowMs }; + } + + record.count++; + rateLimitStore.set(ip, record); + + if (record.count > maxRequests) { + throw new Response("Too Many Requests", { status: 429 }); + } + + return next(); +}); +``` + +## Middleware Execution Order + +``` +Request → Middleware 1 → Middleware 2 → Handler → Middleware 2 → Middleware 1 → Response + +// Example with timing: +loggingMiddleware.server(async ({ next }) => { + console.log('Before handler') + const result = await next() // Calls next middleware/handler + console.log('After handler') + return result +}) +``` + +## Context + +- Request middleware applies to all server requests +- Middleware can add to context using `next({ context: {...} })` +- Order matters - first middleware wraps the entire chain +- Global middleware defined in `app/start.ts` +- Route-specific middleware uses `beforeLoad` +- Server function middleware uses separate pattern (see `mw-function-middleware`) diff --git a/.agents/skills/tanstack-start-best-practices/rules/sf-create-server-fn.md b/.agents/skills/tanstack-start-best-practices/rules/sf-create-server-fn.md new file mode 100644 index 0000000..5bc4090 --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/sf-create-server-fn.md @@ -0,0 +1,146 @@ +# sf-create-server-fn: Use createServerFn for Server-Side Logic + +## Priority: CRITICAL + +## Explanation + +`createServerFn()` creates type-safe server functions that can be called from anywhere - loaders, components, or other server functions. The code inside the handler runs only on the server, with automatic RPC for client calls. + +## Bad Example + +```tsx +// Using fetch directly - no type safety, manual serialization +async function createPost(data: CreatePostInput) { + const response = await fetch("/api/posts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!response.ok) throw new Error("Failed to create post"); + return response.json(); +} + +// Or using API routes - more boilerplate +// api/posts.ts +export async function POST(request: Request) { + const data = await request.json(); + // No type safety from client + const post = await db.posts.create({ data }); + return new Response(JSON.stringify(post)); +} +``` + +## Good Example + +```tsx +// lib/posts.functions.ts +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import { db } from "./db.server"; + +const createPostSchema = z.object({ + title: z.string().min(1).max(200), + content: z.string().min(1), + published: z.boolean().default(false), +}); + +export const createPost = createServerFn({ method: "POST" }) + .validator(createPostSchema) + .handler(async ({ data }) => { + // This code only runs on the server + const post = await db.posts.create({ + data: { + title: data.title, + content: data.content, + published: data.published, + }, + }); + return post; + }); + +// Usage in component +function CreatePostForm() { + const createPostMutation = useServerFn(createPost); + + const handleSubmit = async (formData: FormData) => { + try { + const post = await createPostMutation({ + data: { + title: formData.get("title") as string, + content: formData.get("content") as string, + published: false, + }, + }); + // post is fully typed + console.log("Created post:", post.id); + } catch (error) { + console.error("Failed to create post:", error); + } + }; +} +``` + +## Good Example: GET Function for Data Fetching + +```tsx +// lib/posts.functions.ts +export const getPosts = createServerFn() // GET is default + .handler(async () => { + const posts = await db.posts.findMany({ + orderBy: { createdAt: "desc" }, + take: 20, + }); + return posts; + }); + +export const getPost = createServerFn() + .validator(z.object({ id: z.string() })) + .handler(async ({ data }) => { + const post = await db.posts.findUnique({ + where: { id: data.id }, + }); + if (!post) { + throw notFound(); + } + return post; + }); + +// Usage in route loader +export const Route = createFileRoute("/posts/$postId")({ + loader: async ({ params }) => { + return await getPost({ data: { id: params.postId } }); + }, +}); +``` + +## Good Example: With Context and Dependencies + +```tsx +// Compose server functions +export const getPostWithComments = createServerFn() + .validator(z.object({ postId: z.string() })) + .handler(async ({ data }) => { + const [post, comments] = await Promise.all([ + getPost({ data: { id: data.postId } }), + getComments({ data: { postId: data.postId } }), + ]); + + return { post, comments }; + }); +``` + +## Key Benefits + +- **Type safety**: Input/output types flow through client and server +- **Automatic serialization**: No manual JSON parsing +- **Code splitting**: Server code never reaches client bundle +- **Composable**: Call from loaders, components, or other server functions +- **Validation**: Built-in input validation with schema libraries + +## Context + +- Default method is GET (idempotent, cacheable) +- Use POST for mutations that change data +- Server functions are RPC calls under the hood +- Validation errors are properly typed and serialized +- Import is safe on client - build process replaces with RPC stub diff --git a/.agents/skills/tanstack-start-best-practices/rules/sf-input-validation.md b/.agents/skills/tanstack-start-best-practices/rules/sf-input-validation.md new file mode 100644 index 0000000..e5724b4 --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/sf-input-validation.md @@ -0,0 +1,168 @@ +# sf-input-validation: Always Validate Server Function Inputs + +## Priority: CRITICAL + +## Explanation + +Server functions receive data across the network boundary. Always validate inputs before processing - never trust client data. Use schema validation libraries like Zod for type-safe validation. + +## Bad Example + +```tsx +// No validation - trusting client input directly +export const updateUser = createServerFn({ method: "POST" }).handler(async ({ data }) => { + // data is unknown/any - no type safety + // SQL injection, invalid data, type errors all possible + await db.users.update({ + where: { id: data.id }, + data: { + name: data.name, + email: data.email, + role: data.role, // Could be set to 'admin' by malicious client! + }, + }); +}); + +// Weak validation - type assertion without runtime check +export const deletePost = createServerFn({ method: "POST" }).handler( + async ({ data }: { data: { id: string } }) => { + // Type assertion doesn't validate at runtime + await db.posts.delete({ where: { id: data.id } }); + }, +); +``` + +## Good Example: With Zod Validation + +```tsx +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; + +const updateUserSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1).max(100), + email: z.string().email(), + // Don't allow role updates from client input! +}); + +export const updateUser = createServerFn({ method: "POST" }) + .validator(updateUserSchema) + .handler(async ({ data }) => { + // data is fully typed: { id: string; name: string; email: string } + const user = await db.users.update({ + where: { id: data.id }, + data: { + name: data.name, + email: data.email, + }, + }); + return user; + }); + +// Validation errors are automatically returned to client +// with proper status codes and messages +``` + +## Good Example: Complex Validation + +```tsx +const createOrderSchema = z.object({ + items: z + .array( + z.object({ + productId: z.string().uuid(), + quantity: z.number().int().min(1).max(100), + }), + ) + .min(1) + .max(50), + shippingAddress: z.object({ + street: z.string().min(1), + city: z.string().min(1), + state: z.string().length(2), + zip: z.string().regex(/^\d{5}(-\d{4})?$/), + }), + couponCode: z.string().optional(), +}); + +export const createOrder = createServerFn({ method: "POST" }) + .validator(createOrderSchema) + .handler(async ({ data }) => { + // All data is validated and typed + // Process order safely + }); +``` + +## Good Example: Transform and Refine + +```tsx +const registrationSchema = z + .object({ + email: z.string().email().toLowerCase(), // Transform to lowercase + password: z + .string() + .min(8, "Password must be at least 8 characters") + .regex(/[A-Z]/, "Password must contain uppercase letter") + .regex(/[0-9]/, "Password must contain number"), + confirmPassword: z.string(), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords must match", + path: ["confirmPassword"], + }); + +export const register = createServerFn({ method: "POST" }) + .validator(registrationSchema) + .handler(async ({ data }) => { + // Passwords match, email is lowercase + // Only password needed (confirmPassword was for validation) + const hashedPassword = await hashPassword(data.password); + return await createUser({ + email: data.email, + password: hashedPassword, + }); + }); +``` + +## Sharing Schemas Between Client and Server + +```tsx +// lib/schemas/post.ts - Shared validation schema +import { z } from "zod"; + +export const createPostSchema = z.object({ + title: z.string().min(1).max(200), + content: z.string().min(1), + tags: z.array(z.string()).max(10).optional(), +}); + +export type CreatePostInput = z.infer; + +// lib/posts.functions.ts - Server function +import { createPostSchema } from "./schemas/post"; + +export const createPost = createServerFn({ method: "POST" }) + .validator(createPostSchema) + .handler(async ({ data }) => { + /* ... */ + }); + +// components/CreatePostForm.tsx - Client form validation +import { createPostSchema, type CreatePostInput } from "@/lib/schemas/post"; + +function CreatePostForm() { + const form = useForm({ + resolver: zodResolver(createPostSchema), + }); + // Same validation client and server side +} +``` + +## Context + +- Network boundary = trust boundary - always validate +- Use `.validator()` before `.handler()` in the chain +- Validation errors return proper HTTP status codes +- Share schemas between client forms and server functions +- Strip or ignore fields clients shouldn't control (like `role`, `isAdmin`) +- Consider rate limiting for mutation endpoints diff --git a/.agents/skills/tanstack-start-best-practices/rules/ssr-hydration-safety.md b/.agents/skills/tanstack-start-best-practices/rules/ssr-hydration-safety.md new file mode 100644 index 0000000..e686577 --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/ssr-hydration-safety.md @@ -0,0 +1,184 @@ +# ssr-hydration-safety: Prevent Hydration Mismatches + +## Priority: MEDIUM + +## Explanation + +Hydration errors occur when server-rendered HTML doesn't match what the client expects. This causes React to discard server HTML and re-render, losing SSR benefits. Ensure consistent rendering between server and client. + +## Bad Example + +```tsx +// Using Date.now() - different on server and client +function Timestamp() { + return Generated at: {Date.now()}; +} + +// Using Math.random() - always different +function RandomGreeting() { + const greetings = ["Hello", "Hi", "Hey"]; + return

{greetings[Math.floor(Math.random() * 3)]}

; +} + +// Checking window - doesn't exist on server +function DeviceInfo() { + return Width: {window.innerWidth}px; // Error on server +} + +// Conditional render based on time +function TimeBasedContent() { + const hour = new Date().getHours(); + return hour < 12 ? : ; + // Server might render Morning, client renders Evening +} +``` + +## Good Example: Consistent Server/Client Rendering + +```tsx +// Pass data from server to avoid mismatch +export const Route = createFileRoute("/dashboard")({ + loader: async () => { + return { + generatedAt: Date.now(), + }; + }, + component: Dashboard, +}); + +function Dashboard() { + const { generatedAt } = Route.useLoaderData(); + // Both server and client use same value + return Generated at: {generatedAt}; +} +``` + +## Good Example: Client-Only Components + +```tsx +// Use lazy loading for client-only features +import { lazy, Suspense } from "react"; + +const ClientOnlyMap = lazy(() => import("./Map")); + +function LocationPage() { + return ( +
+

Our Location

+ }> + + +
+ ); +} + +// Or use useEffect for client-only state +function WindowSize() { + const [size, setSize] = useState<{ width: number; height: number } | null>(null); + + useEffect(() => { + setSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + }, []); + + if (!size) { + return Loading dimensions...; + } + + return ( + + {size.width} x {size.height} + + ); +} +``` + +## Good Example: Stable Random Values + +```tsx +// Generate random value on server, pass to client +export const Route = createFileRoute('/onboarding')({ + loader: () => ({ + welcomeVariant: Math.floor(Math.random() * 3), + }), + component: Onboarding, +}) + +function Onboarding() { + const { welcomeVariant } = Route.useLoaderData() + const messages = ['Welcome aboard!', 'Let's get started!', 'Great to have you!'] + + return

{messages[welcomeVariant]}

// Same on server and client +} +``` + +## Good Example: Handling Time Zones + +```tsx +// Pass formatted date from server +export const Route = createFileRoute("/posts/$postId")({ + loader: async ({ params }) => { + const post = await getPost(params.postId); + return { + ...post, + // Format on server to avoid timezone mismatch + formattedDate: new Intl.DateTimeFormat("en-US", { + dateStyle: "long", + timeStyle: "short", + timeZone: "UTC", // Consistent timezone + }).format(post.createdAt), + }; + }, + component: PostPage, +}); + +// Or use client-only formatting +function RelativeTime({ date }: { date: Date }) { + const [formatted, setFormatted] = useState(""); + + useEffect(() => { + // Format in user's timezone after hydration + setFormatted(formatDistanceToNow(date, { addSuffix: true })); + }, [date]); + + // Show absolute date initially (same server/client) + return ; +} +``` + +## Common Hydration Mismatch Causes + +| Issue | Solution | +| --------------------------- | ------------------------------------- | +| `Date.now()` / `new Date()` | Pass timestamp from loader | +| `Math.random()` | Generate on server, pass to client | +| `window` / `document` | Use useEffect or lazy loading | +| User timezone differences | Use UTC or client-only formatting | +| Browser-specific APIs | Check `typeof window !== 'undefined'` | +| Extension-injected content | Use `suppressHydrationWarning` | + +## Debugging Hydration Errors + +```tsx +// React 18+ provides detailed hydration error messages +// Check the console for: +// - "Text content does not match" +// - "Hydration failed because" +// - The specific DOM element causing the issue + +// For difficult cases, use suppressHydrationWarning sparingly +function UserContent({ html }: { html: string }) { + return
; +} +``` + +## Context + +- Hydration compares server HTML with client render +- Mismatches force full client re-render (slow, flash) +- Use loaders to pass dynamic data consistently +- Defer client-only content with useEffect or Suspense +- Test SSR by disabling JavaScript and checking render +- Development mode shows hydration warnings in console diff --git a/.agents/skills/tanstack-start-best-practices/rules/ssr-prerender.md b/.agents/skills/tanstack-start-best-practices/rules/ssr-prerender.md new file mode 100644 index 0000000..9c03d58 --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/ssr-prerender.md @@ -0,0 +1,190 @@ +# ssr-prerender: Configure Static Prerendering and ISR + +## Priority: MEDIUM + +## Explanation + +Static prerendering generates HTML at build time for pages that don't require request-time data. Incremental Static Regeneration (ISR) extends this by revalidating cached pages on a schedule. Use these for better performance and lower server costs. + +## Bad Example + +```tsx +// SSR for completely static content - wasteful +export const Route = createFileRoute("/about")({ + loader: async () => { + // Fetching static content on every request + const content = await fetchAboutPageContent(); + return { content }; + }, +}); + +// Or no caching headers for semi-static content +export const Route = createFileRoute("/blog/$slug")({ + loader: async ({ params }) => { + const post = await fetchPost(params.slug); + return { post }; + // Every request hits the database + }, +}); +``` + +## Good Example: Static Prerendering + +```tsx +// app.config.ts +import { defineConfig } from "@tanstack/react-start/config"; + +export default defineConfig({ + server: { + prerender: { + // Routes to prerender at build time + routes: ["/", "/about", "/contact", "/pricing"], + // Or crawl from root + crawlLinks: true, + }, + }, +}); + +// routes/about.tsx - Will be prerendered +export const Route = createFileRoute("/about")({ + loader: async () => { + // Runs at BUILD time, not request time + const content = await fetchAboutPageContent(); + return { content }; + }, + component: AboutPage, +}); +``` + +## Good Example: Dynamic Prerendering + +```tsx +// app.config.ts +export default defineConfig({ + server: { + prerender: { + // Generate routes dynamically + routes: async () => { + const posts = await db.posts.findMany({ + where: { published: true }, + select: { slug: true }, + }); + + return ["/", "/blog", ...posts.map((p) => `/blog/${p.slug}`)]; + }, + }, + }, +}); +``` + +## Good Example: ISR with Revalidation + +```tsx +// routes/blog/$slug.tsx +import { createFileRoute } from "@tanstack/react-router"; +import { setHeaders } from "@tanstack/react-start/server"; + +export const Route = createFileRoute("/blog/$slug")({ + loader: async ({ params }) => { + const post = await fetchPost(params.slug); + + // ISR: Cache for 60 seconds, then revalidate + setHeaders({ + "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300", + }); + + return { post }; + }, + component: BlogPost, +}); + +// First request: SSR and cache +// Next 60 seconds: Serve cached version +// After 60 seconds: Serve stale, revalidate in background +// After 300 seconds: Full SSR again +``` + +## Good Example: Hybrid Static/Dynamic + +```tsx +// routes/products.tsx - Prerendered +export const Route = createFileRoute("/products")({ + loader: async () => { + // Featured products - prerendered at build + const featured = await fetchFeaturedProducts(); + return { featured }; + }, +}); + +// routes/products/$productId.tsx - ISR +export const Route = createFileRoute("/products/$productId")({ + loader: async ({ params }) => { + const product = await fetchProduct(params.productId); + + if (!product) throw notFound(); + + // Cache product pages for 5 minutes + setHeaders({ + "Cache-Control": "public, s-maxage=300, stale-while-revalidate=600", + }); + + return { product }; + }, +}); + +// routes/cart.tsx - Always SSR (user-specific) +export const Route = createFileRoute("/cart")({ + loader: async ({ context }) => { + // No caching - user-specific data + setHeaders({ + "Cache-Control": "private, no-store", + }); + + const cart = await fetchUserCart(context.user.id); + return { cart }; + }, +}); +``` + +## Good Example: On-Demand Revalidation + +```tsx +// API route to trigger revalidation +// app/routes/api/revalidate.ts +export const APIRoute = createAPIFileRoute("/api/revalidate")({ + POST: async ({ request }) => { + const { secret, path } = await request.json(); + + // Verify secret + if (secret !== process.env.REVALIDATE_SECRET) { + return json({ error: "Invalid secret" }, { status: 401 }); + } + + // Trigger revalidation (implementation depends on hosting) + await revalidatePath(path); + + return json({ revalidated: true, path }); + }, +}); + +// Usage: POST /api/revalidate { "secret": "...", "path": "/blog/my-post" } +``` + +## Cache-Control Directives + +| Directive | Meaning | +| -------------------------- | ---------------------------------- | +| `s-maxage=N` | CDN cache duration (seconds) | +| `max-age=N` | Browser cache duration | +| `stale-while-revalidate=N` | Serve stale while fetching fresh | +| `private` | Don't cache on CDN (user-specific) | +| `no-store` | Never cache | + +## Context + +- Prerendering happens at build time - no request context +- ISR requires CDN/edge support (Vercel, Cloudflare, etc.) +- Use prerendering for truly static pages (about, pricing) +- Use ISR for content that changes but not per-request +- Always SSR for user-specific or real-time data +- Test with production builds - dev server is always SSR diff --git a/.agents/skills/tanstack-start-best-practices/rules/ssr-streaming.md b/.agents/skills/tanstack-start-best-practices/rules/ssr-streaming.md new file mode 100644 index 0000000..bd66e7d --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/ssr-streaming.md @@ -0,0 +1,201 @@ +# ssr-streaming: Implement Streaming SSR for Faster TTFB + +## Priority: MEDIUM + +## Explanation + +Streaming SSR sends HTML chunks to the browser as they're ready, rather than waiting for all data to load. This improves Time to First Byte (TTFB) and perceived performance by showing content progressively. + +## Bad Example + +```tsx +// Blocking SSR - waits for everything +export const Route = createFileRoute("/dashboard")({ + loader: async ({ context: { queryClient } }) => { + // All of these must complete before ANY HTML is sent + await Promise.all([ + queryClient.ensureQueryData(userQueries.profile()), // 200ms + queryClient.ensureQueryData(dashboardQueries.stats()), // 500ms + queryClient.ensureQueryData(activityQueries.recent()), // 300ms + queryClient.ensureQueryData(notificationQueries.all()), // 400ms + ]); + // TTFB: 500ms (slowest query) + }, +}); +``` + +## Good Example: Stream Non-Critical Content + +```tsx +// routes/dashboard.tsx +export const Route = createFileRoute("/dashboard")({ + loader: async ({ context: { queryClient } }) => { + // Only await critical above-the-fold data + await queryClient.ensureQueryData(userQueries.profile()); + + // Start fetching but don't await + queryClient.prefetchQuery(dashboardQueries.stats()); + queryClient.prefetchQuery(activityQueries.recent()); + queryClient.prefetchQuery(notificationQueries.all()); + + // HTML starts streaming immediately after profile loads + // TTFB: 200ms + }, + component: DashboardPage, +}); + +function DashboardPage() { + // Critical data - ready immediately (from loader) + const { data: user } = useSuspenseQuery(userQueries.profile()); + + return ( +
+
+ + {/* Non-critical - streams in with Suspense */} + }> + + + + }> + + + + }> + + +
+ ); +} + +// Each section loads independently and streams when ready +function DashboardStats() { + const { data: stats } = useSuspenseQuery(dashboardQueries.stats()); + return ; +} +``` + +## Good Example: Nested Suspense Boundaries + +```tsx +function DashboardPage() { + const { data: user } = useSuspenseQuery(userQueries.profile()); + + return ( +
+
+ +
+ {/* Left column streams together */} + }> + + + + {/* Right column streams independently */} + }> + + +
+
+ ); +} + +function LeftColumn() { + // These load together (same Suspense boundary) + const { data: stats } = useSuspenseQuery(dashboardQueries.stats()); + const { data: chart } = useSuspenseQuery(dashboardQueries.chartData()); + + return ( +
+ + +
+ ); +} +``` + +## Good Example: Progressive Enhancement + +```tsx +export const Route = createFileRoute("/posts/$postId")({ + loader: async ({ params, context: { queryClient } }) => { + // Critical: post content (await) + await queryClient.ensureQueryData(postQueries.detail(params.postId)); + + // Start but don't block: comments, related posts + queryClient.prefetchQuery(commentQueries.forPost(params.postId)); + queryClient.prefetchQuery(postQueries.related(params.postId)); + }, + component: PostPage, +}); + +function PostPage() { + const { postId } = Route.useParams(); + const { data: post } = useSuspenseQuery(postQueries.detail(postId)); + + return ( +
+ {/* Streams immediately */} + + + + {/* Streams when ready */} + }> + + + + }> + + +
+ ); +} +``` + +## Good Example: Error Boundaries with Streaming + +```tsx +function DashboardPage() { + return ( +
+
+ + {/* Each section handles its own errors */} + }> + }> + + + + + }> + }> + + + +
+ ); +} +``` + +## Streaming Timeline + +``` +Traditional SSR: +Request → [Wait for all data...] → Send complete HTML → Render + +Streaming SSR: +Request → Send shell HTML → Stream chunk 1 → Stream chunk 2 → Stream chunk 3 → Done + ↓ ↓ ↓ ↓ + Browser renders Shows content More content Complete + skeleton progressively +``` + +## Context + +- Suspense boundaries define streaming chunks +- Place boundaries around slow or non-critical content +- Critical path data should still be awaited in loader +- Each Suspense boundary can error independently +- Works with React 18's streaming SSR +- Monitor TTFB to verify streaming is working +- Consider network conditions - too many chunks can slow total load diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 3bbfa10..d002376 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -8,6 +8,26 @@ jobs: deploy: name: Deploy to Cloudflare (dev) runs-on: ubuntu-latest + env: + ALCHEMY_PASSWORD: ${{ secrets.ALCHEMY_PASSWORD }} + ALCHEMY_STATE_TOKEN: ${{ secrets.ALCHEMY_STATE_TOKEN }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }} + # Secrets — same for dev and prod + BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} + POLAR_ACCESS_TOKEN: ${{ secrets.POLAR_ACCESS_TOKEN }} + GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + GITHUB_CLIENT_SECRET: ${{ secrets.GITHUB_CLIENT_SECRET }} + GMAIL_APP_PASSWORD: ${{ secrets.GMAIL_APP_PASSWORD }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GITHUB_CLIENT_ID: ${{ secrets.GITHUB_CLIENT_ID }} + GMAIL_USER: ${{ secrets.GMAIL_USER }} + # Dev-specific + DATABASE_URL: ${{ secrets.DATABASE_URL_DEV }} + CORS_ORIGIN: https://open-clock-web-dev.ludvig1411.workers.dev + BETTER_AUTH_URL: https://open-clock-server-dev.ludvig1411.workers.dev + POLAR_SUCCESS_URL: https://open-clock-web-dev.ludvig1411.workers.dev/success?checkout_id={CHECKOUT_ID} steps: - uses: actions/checkout@v4 @@ -22,23 +42,3 @@ jobs: - name: Deploy run: bun run deploy:dev working-directory: packages/infra - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - ALCHEMY_PASSWORD: ${{ secrets.ALCHEMY_PASSWORD }} - ALCHEMY_STATE_TOKEN: ${{ secrets.ALCHEMY_STATE_TOKEN }} - # Secrets — same for dev and prod - BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} - POLAR_ACCESS_TOKEN: ${{ secrets.POLAR_ACCESS_TOKEN }} - GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }} - GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} - GITHUB_CLIENT_SECRET: ${{ secrets.GITHUB_CLIENT_SECRET }} - GMAIL_APP_PASSWORD: ${{ secrets.GMAIL_APP_PASSWORD }} - # Dev-specific database - DATABASE_URL: ${{ secrets.DATABASE_URL_DEV }} - # Dev-specific non-sensitive config - CORS_ORIGIN: https://open-clock-web-dev.ludvig1411.workers.dev - BETTER_AUTH_URL: https://open-clock-server-dev.ludvig1411.workers.dev - POLAR_SUCCESS_URL: https://open-clock-web-dev.ludvig1411.workers.dev/success?checkout_id={CHECKOUT_ID} - GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} - GITHUB_CLIENT_ID: ${{ secrets.GITHUB_CLIENT_ID }} - GMAIL_USER: ${{ secrets.GMAIL_USER }} diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index e4d432f..9e56608 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -9,6 +9,26 @@ jobs: name: Deploy to Cloudflare (prod) runs-on: ubuntu-latest environment: production + env: + ALCHEMY_PASSWORD: ${{ secrets.ALCHEMY_PASSWORD }} + ALCHEMY_STATE_TOKEN: ${{ secrets.ALCHEMY_STATE_TOKEN }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }} + # Secrets — same for dev and prod + BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} + POLAR_ACCESS_TOKEN: ${{ secrets.POLAR_ACCESS_TOKEN }} + GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + GITHUB_CLIENT_SECRET: ${{ secrets.GITHUB_CLIENT_SECRET }} + GMAIL_APP_PASSWORD: ${{ secrets.GMAIL_APP_PASSWORD }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GITHUB_CLIENT_ID: ${{ secrets.GITHUB_CLIENT_ID }} + GMAIL_USER: ${{ secrets.GMAIL_USER }} + # Prod-specific + DATABASE_URL: ${{ secrets.DATABASE_URL_PROD }} + CORS_ORIGIN: https://open-clock-web-prod.ludvig1411.workers.dev + BETTER_AUTH_URL: https://open-clock-server-prod.ludvig1411.workers.dev + POLAR_SUCCESS_URL: https://open-clock-web-prod.ludvig1411.workers.dev/success?checkout_id={CHECKOUT_ID} steps: - uses: actions/checkout@v4 @@ -23,23 +43,3 @@ jobs: - name: Deploy run: bun run deploy:prod working-directory: packages/infra - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - ALCHEMY_PASSWORD: ${{ secrets.ALCHEMY_PASSWORD }} - ALCHEMY_STATE_TOKEN: ${{ secrets.ALCHEMY_STATE_TOKEN }} - # Secrets — same for dev and prod - BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }} - POLAR_ACCESS_TOKEN: ${{ secrets.POLAR_ACCESS_TOKEN }} - GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }} - GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} - GITHUB_CLIENT_SECRET: ${{ secrets.GITHUB_CLIENT_SECRET }} - GMAIL_APP_PASSWORD: ${{ secrets.GMAIL_APP_PASSWORD }} - # Prod-specific database - DATABASE_URL: ${{ secrets.DATABASE_URL_PROD }} - # Prod-specific non-sensitive config - CORS_ORIGIN: https://open-clock-web-prod.ludvig1411.workers.dev - BETTER_AUTH_URL: https://open-clock-server-prod.ludvig1411.workers.dev - POLAR_SUCCESS_URL: https://open-clock-web-prod.ludvig1411.workers.dev/success?checkout_id={CHECKOUT_ID} - GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} - GITHUB_CLIENT_ID: ${{ secrets.GITHUB_CLIENT_ID }} - GMAIL_USER: ${{ secrets.GMAIL_USER }} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0444aa6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +See AGENTS.md & .agents/skills diff --git a/apps/server/package.json b/apps/server/package.json index dc80890..ee5a90c 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -3,6 +3,7 @@ "type": "module", "main": "src/index.ts", "scripts": { + "dev": "wrangler dev src/index.ts", "build": "tsdown", "check-types": "tsc -b", "compile": "bun build --compile --minify --sourcemap --bytecode ./src/index.ts --outfile server" diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 6612766..97f6f26 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -10,12 +10,22 @@ import { mountTrpc } from "./trpc/mount"; const app = new Hono(); app.use(logger()); +// Accept requests from the primary configured origin plus all localhost ports +// used during local development. This ensures CORS headers are present on all +// responses — including 4xx errors returned by Better Auth on preflight. +const corsOrigins = [ + env.CORS_ORIGIN, + "http://localhost:3001", + "http://localhost:3002", + "http://localhost:3003", +]; + app.use( "/*", cors({ - origin: env.CORS_ORIGIN, + origin: corsOrigins, allowMethods: ["GET", "POST", "OPTIONS"], - allowHeaders: ["Content-Type", "Authorization"], + allowHeaders: ["Content-Type", "Authorization", "User-Agent"], credentials: true, }), ); diff --git a/apps/web/components.json b/apps/web/components.json index a02adde..d0392b8 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -11,6 +11,7 @@ "prefix": "" }, "iconLibrary": "lucide", + "rtl": false, "aliases": { "components": "@/components", "utils": "@open-learn/ui/lib/utils", diff --git a/apps/web/components.json.bak b/apps/web/components.json.bak new file mode 100644 index 0000000..14d6dad --- /dev/null +++ b/apps/web/components.json.bak @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-luma", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "../../packages/ui/src/styles/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@open-learn/ui/lib/utils", + "ui": "@open-learn/ui/components", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/apps/web/package.json b/apps/web/package.json index 66c0328..e3c0d2c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,6 +8,7 @@ "serve": "vite preview", "start": "vite", "check-types": "tsc --noEmit", + "dev": "vite dev", "dev:bare": "vite dev" }, "dependencies": { @@ -33,11 +34,13 @@ "@trpc/tanstack-react-query": "^11.7.2", "ai": "catalog:", "better-auth": "catalog:", + "date-fns": "^4.1.0", "dotenv": "catalog:", "lucide-react": "catalog:", "motion": "^12.36.0", "next-themes": "catalog:", "react": "^19.2.3", + "react-big-calendar": "^1.19.4", "react-dom": "^19.2.3", "sonner": "catalog:", "streamdown": "^1.6.10", diff --git a/apps/web/src/features/auth/components/sign-in-form.tsx b/apps/web/src/features/auth/components/sign-in-form.tsx index b5d6137..d56e810 100644 --- a/apps/web/src/features/auth/components/sign-in-form.tsx +++ b/apps/web/src/features/auth/components/sign-in-form.tsx @@ -26,7 +26,7 @@ export default function SignInForm({ onSwitchToSignUp: () => void; invitationId?: string; }) { - const navigate = useNavigate({ from: "/" }); + const navigate = useNavigate(); const { isPending } = authClient.useSession(); const form = useForm({ diff --git a/apps/web/src/features/auth/components/sign-up-form.tsx b/apps/web/src/features/auth/components/sign-up-form.tsx index 73267a4..6d28c53 100644 --- a/apps/web/src/features/auth/components/sign-up-form.tsx +++ b/apps/web/src/features/auth/components/sign-up-form.tsx @@ -26,7 +26,7 @@ export default function SignUpForm({ onSwitchToSignIn: () => void; invitationId?: string; }) { - const navigate = useNavigate({ from: "/" }); + const navigate = useNavigate(); const { isPending } = authClient.useSession(); const form = useForm({ diff --git a/apps/web/src/features/auth/constants/index.ts b/apps/web/src/features/auth/constants/index.ts index 06ad241..7adaee4 100644 --- a/apps/web/src/features/auth/constants/index.ts +++ b/apps/web/src/features/auth/constants/index.ts @@ -1,6 +1,6 @@ export const AUTH_REDIRECT = { - afterSignIn: "/", - afterSignUp: "/", + afterSignIn: "/app", + afterSignUp: "/app", afterSignOut: "/login", } as const; diff --git a/apps/web/src/features/home/pages/home-page.tsx b/apps/web/src/features/home/pages/home-page.tsx index 7a44512..3ab58ae 100644 --- a/apps/web/src/features/home/pages/home-page.tsx +++ b/apps/web/src/features/home/pages/home-page.tsx @@ -40,7 +40,7 @@ export default function HomePage() { >
@@ -67,7 +67,7 @@ export default function HomePage() {
@@ -93,7 +93,7 @@ export default function HomePage() {

+ + + + ); +} diff --git a/apps/web/src/features/time-tracker/components/calendar-entry-sheet.tsx b/apps/web/src/features/time-tracker/components/calendar-entry-sheet.tsx new file mode 100644 index 0000000..c31f1f6 --- /dev/null +++ b/apps/web/src/features/time-tracker/components/calendar-entry-sheet.tsx @@ -0,0 +1,430 @@ +import type { TaskListItem } from "@open-learn/api/modules/task/task.schema"; +import type { + TrackerEntry, + TrackerProject, + TrackerTag, +} from "@open-learn/api/modules/time-tracker/time-tracker.schema"; +import type { CalendarSheetMode } from "../utils/calendar"; +import type { TrackerOverviewRange } from "../utils/date-time"; + +import { useForm } from "@tanstack/react-form"; +import { useEffect, useMemo, useState } from "react"; +import { z } from "zod"; +import { Button } from "@open-learn/ui/components/button"; +import { + Field, + FieldContent, + FieldError, + FieldGroup, + FieldLabel, +} from "@open-learn/ui/components/field"; +import { Input } from "@open-learn/ui/components/input"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@open-learn/ui/components/sheet"; + +import { useCreateManualEntry, useDeleteEntry, useUpdateEntry } from "../services/mutations"; +import { getCompatibleTaskId } from "../utils/task-reference"; +import { getDefaultSlotEnd } from "../utils/calendar"; +import { ActivityReferenceInput } from "./activity-reference-input"; +import { CompactBillableToggle } from "./compact-billable-toggle"; +import { CompactProjectPicker } from "./compact-project-picker"; +import { CompactTagPicker } from "./compact-tag-picker"; +import { + combineDateAndTime, + getEditableEntryValues, + toLocalDateInputValue, + toLocalTimeInputValue, +} from "../utils/date-time"; + +function getCalendarEntryDateRange(date: string, startTime: string, endTime: string) { + const startAt = combineDateAndTime(date, startTime); + const endAt = combineDateAndTime(date, endTime); + + if (!startAt || !endAt) { + return null; + } + + if (endAt < startAt) { + const nextDayEnd = new Date(endAt); + nextDayEnd.setDate(nextDayEnd.getDate() + 1); + + return { startAt, endAt: nextDayEnd }; + } + + return { startAt, endAt }; +} + +const calendarEntrySchema = z + .object({ + description: z.string().max(500, "Description must be 500 characters or less"), + date: z.string().min(1, "Date is required"), + startTime: z.string().min(1, "Start time is required"), + endTime: z.string().min(1, "End time is required"), + projectId: z.number().nullable(), + taskId: z.number().nullable(), + tagIds: z.array(z.number()), + isBillable: z.boolean(), + }) + .superRefine((value, ctx) => { + const entryRange = getCalendarEntryDateRange(value.date, value.startTime, value.endTime); + + if (!entryRange || entryRange.endAt <= entryRange.startAt) { + ctx.addIssue({ + code: "custom", + message: "End time must be after start time", + path: ["endTime"], + }); + } + }); + +interface CalendarEntrySheetProps { + open: boolean; + mode: CalendarSheetMode; + entry: TrackerEntry | null; + selection: { start: Date; end: Date } | null; + projects: TrackerProject[]; + tasks: TaskListItem[]; + tags: TrackerTag[]; + range: TrackerOverviewRange; + onOpenChange: (open: boolean) => void; +} + +function getInitialValues( + entry: TrackerEntry | null, + selection: { start: Date; end: Date } | null, +) { + if (entry) { + return getEditableEntryValues(entry); + } + + const start = selection?.start ?? new Date(); + const end = selection?.end ?? getDefaultSlotEnd(start); + + return { + description: "", + date: toLocalDateInputValue(start), + startTime: toLocalTimeInputValue(start), + endTime: toLocalTimeInputValue(end), + projectId: null as number | null, + taskId: null as number | null, + tagIds: [] as number[], + isBillable: false, + }; +} + +export function CalendarEntrySheet({ + open, + mode, + entry, + selection, + projects, + tasks, + tags, + range, + onOpenChange, +}: CalendarEntrySheetProps) { + const initialValues = useMemo(() => getInitialValues(entry, selection), [entry, selection]); + const [activityMode, setActivityMode] = useState<"description" | "task">( + entry?.task ? "task" : "description", + ); + const createEntry = useCreateManualEntry(range); + const updateEntry = useUpdateEntry(range); + const deleteEntry = useDeleteEntry(range); + const form = useForm({ + defaultValues: initialValues, + validators: { onSubmit: calendarEntrySchema }, + onSubmit: async ({ value }) => { + const entryRange = getCalendarEntryDateRange(value.date, value.startTime, value.endTime); + + if (!entryRange || entryRange.endAt <= entryRange.startAt) { + return; + } + + const payload = { + description: value.description.trim(), + projectId: value.projectId, + taskId: value.taskId, + tagIds: value.tagIds, + isBillable: value.isBillable, + startAt: entryRange.startAt.toISOString(), + endAt: entryRange.endAt.toISOString(), + }; + + if (mode === "edit" && entry) { + await updateEntry.mutateAsync({ + entryId: entry.id, + ...payload, + }); + } else { + await createEntry.mutateAsync(payload); + } + + onOpenChange(false); + }, + }); + + useEffect(() => { + if (!open) { + return; + } + + form.reset(initialValues); + setActivityMode(entry?.task ? "task" : "description"); + }, [entry, form, initialValues, open]); + + return ( + + + + {mode === "edit" ? "Edit entry" : "Create entry"} + + {mode === "edit" + ? "Update the tracked time, activity, and metadata for this entry." + : "Add a finished time entry directly from the calendar."} + + + +
{ + event.preventDefault(); + event.stopPropagation(); + form.handleSubmit(); + }} + className="flex min-h-0 flex-1 flex-col" + > +
+ ({ values: state.values })}> + {({ values }) => ( + <> + + {(descriptionField) => { + const descriptionInvalid = + descriptionField.state.meta.isTouched && + !descriptionField.state.meta.isValid; + + return ( + + + + Activity + + + + {(taskField) => ( + + )} + + + + + + ); + }} + + +
+ + {(field) => ( + + + Date + + + field.handleChange(event.target.value)} + aria-invalid={field.state.meta.isTouched && !field.state.meta.isValid} + className="h-10" + /> + + + + )} + + +
+ + {(field) => ( + + + Start + + + field.handleChange(event.target.value)} + aria-invalid={ + field.state.meta.isTouched && !field.state.meta.isValid + } + className="h-10" + /> + + + )} + + + + {(field) => ( + + + End + + + field.handleChange(event.target.value)} + aria-invalid={ + field.state.meta.isTouched && !field.state.meta.isValid + } + className="h-10" + /> + + + + )} + +
+
+ +
+ + + Project + + + + {(projectField) => ( + + {(taskField) => ( + { + projectField.handleChange(projectId); + taskField.handleChange( + getCompatibleTaskId(tasks, taskField.state.value, projectId), + ); + }} + projects={projects} + range={range} + /> + )} + + )} + + + + + + + Tags + + + + {(tagField) => ( + + )} + + + +
+ + + + Billing + + + + {(billableField) => ( + + )} + + + + + )} +
+
+ + + {mode === "edit" && entry ? ( + + ) : null} +
+ + ({ isSubmitting: state.isSubmitting })}> + {({ isSubmitting }) => ( + + )} + +
+
+
+
+
+ ); +} diff --git a/apps/web/src/features/time-tracker/components/calendar-event.tsx b/apps/web/src/features/time-tracker/components/calendar-event.tsx new file mode 100644 index 0000000..1a77c01 --- /dev/null +++ b/apps/web/src/features/time-tracker/components/calendar-event.tsx @@ -0,0 +1,16 @@ +import type { EventProps } from "react-big-calendar"; +import type { CalendarEntryEvent } from "../utils/calendar"; + +import { formatCalendarEventDuration } from "../utils/calendar"; + +export function CalendarEvent({ event }: EventProps) { + return ( +
+
{event.title}
+
{event.resource.projectName}
+
+ {formatCalendarEventDuration(event.resource.durationSeconds)} +
+
+ ); +} diff --git a/apps/web/src/features/time-tracker/components/calendar-toolbar.tsx b/apps/web/src/features/time-tracker/components/calendar-toolbar.tsx new file mode 100644 index 0000000..3efd5aa --- /dev/null +++ b/apps/web/src/features/time-tracker/components/calendar-toolbar.tsx @@ -0,0 +1,125 @@ +import type { TrackerProject } from "@open-learn/api/modules/time-tracker/time-tracker.schema"; +import type { CalendarBillableFilter } from "../constants/calendar"; +import type { CalendarViewKey } from "../utils/calendar"; + +import { Badge } from "@open-learn/ui/components/badge"; +import { Button } from "@open-learn/ui/components/button"; +import { ButtonGroup } from "@open-learn/ui/components/button-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@open-learn/ui/components/select"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; + +import { + CALENDAR_BILLABLE_FILTER_OPTIONS, + CALENDAR_COPY, + CALENDAR_VIEW_OPTIONS, +} from "../constants/calendar"; + +interface CalendarToolbarProps { + title: string; + view: CalendarViewKey; + projectFilter: string; + billableFilter: CalendarBillableFilter; + projects: TrackerProject[]; + onViewChange: (view: CalendarViewKey) => void; + onProjectFilterChange: (value: string) => void; + onBillableFilterChange: (value: CalendarBillableFilter) => void; + onToday: () => void; + onPrevious: () => void; + onNext: () => void; +} + +export function CalendarToolbar({ + title, + view, + projectFilter, + billableFilter, + projects, + onViewChange, + onProjectFilterChange, + onBillableFilterChange, + onToday, + onPrevious, + onNext, +}: CalendarToolbarProps) { + return ( +
+
+ + + + + + + {CALENDAR_COPY.myEntries} + +
+ +
+
{title}
+
+ +
+ + {CALENDAR_VIEW_OPTIONS.map((option) => ( + + ))} + + + + + +
+
+ ); +} diff --git a/apps/web/src/features/time-tracker/components/tracker-calendar.tsx b/apps/web/src/features/time-tracker/components/tracker-calendar.tsx new file mode 100644 index 0000000..4cafa75 --- /dev/null +++ b/apps/web/src/features/time-tracker/components/tracker-calendar.tsx @@ -0,0 +1,90 @@ +import type { ComponentType } from "react"; +import type { CalendarProps, SlotInfo } from "react-big-calendar"; +import type { CalendarEntryEvent, CalendarViewKey } from "../utils/calendar"; + +import { Calendar } from "react-big-calendar"; +import withDragAndDrop from "react-big-calendar/lib/addons/dragAndDrop"; + +import { CalendarEvent } from "./calendar-event"; +import { CALENDAR_FORMATS, calendarLocalizer, getCalendarEventClassName } from "../utils/calendar"; +import "react-big-calendar/lib/css/react-big-calendar.css"; +import "react-big-calendar/lib/addons/dragAndDrop/styles.css"; +import "../styles/react-big-calendar.css"; + +interface CalendarInteractionArgs { + event: CalendarEntryEvent; + start: string | Date; + end: string | Date; + isAllDay?: boolean; +} + +interface TrackerCalendarProps { + view: CalendarViewKey; + date: Date; + events: CalendarEntryEvent[]; + onViewChange: (view: CalendarViewKey) => void; + onNavigate: (date: Date) => void; + onSelectEvent: (event: CalendarEntryEvent) => void; + onSelectSlot: (slot: SlotInfo) => void; + onEventDrop: (args: CalendarInteractionArgs) => void; + onEventResize: (args: CalendarInteractionArgs) => void; +} + +const DnDCalendar = withDragAndDrop( + Calendar as ComponentType>, +); + +const minTime = new Date(1970, 0, 1, 0, 0, 0, 0); +const maxTime = new Date(1970, 0, 1, 23, 59, 0, 0); +const scrollToTime = new Date(1970, 0, 1, 8, 0, 0, 0); + +export function TrackerCalendar({ + view, + date, + events, + onViewChange, + onNavigate, + onSelectEvent, + onSelectSlot, + onEventDrop, + onEventResize, +}: TrackerCalendarProps) { + return ( +
+ event.resource.canEdit} + resizableAccessor={(event) => event.resource.canEdit} + onNavigate={onNavigate} + onView={(nextView) => onViewChange(nextView as CalendarViewKey)} + onSelectSlot={onSelectSlot} + onSelectEvent={(event) => onSelectEvent(event)} + onEventDrop={onEventDrop} + onEventResize={onEventResize} + eventPropGetter={(event) => ({ className: getCalendarEventClassName(event) })} + components={{ + event: CalendarEvent, + }} + messages={{ + showMore: (count) => `+${count} more`, + }} + /> +
+ ); +} diff --git a/apps/web/src/features/time-tracker/constants/calendar.ts b/apps/web/src/features/time-tracker/constants/calendar.ts new file mode 100644 index 0000000..f04849c --- /dev/null +++ b/apps/web/src/features/time-tracker/constants/calendar.ts @@ -0,0 +1,28 @@ +import type { CalendarViewKey } from "../utils/calendar"; + +export type CalendarBillableFilter = "all" | "billable" | "non-billable"; + +export const CALENDAR_COPY = { + pageTitle: "Calendar", + pageDescription: "Browse and manage tracked time in a calendar view.", + myEntries: "My entries", + emptyTitle: "No tracked time in this range", + emptyDescription: + "Switch the date range, clear filters, or create a manual entry to start filling your calendar.", + createEntry: "Create entry", +} as const; + +export const CALENDAR_VIEW_OPTIONS: Array<{ label: string; value: CalendarViewKey }> = [ + { label: "Week", value: "week" }, + { label: "Day", value: "day" }, + { label: "Month", value: "month" }, +]; + +export const CALENDAR_BILLABLE_FILTER_OPTIONS: Array<{ + label: string; + value: CalendarBillableFilter; +}> = [ + { label: "All time", value: "all" }, + { label: "Billable", value: "billable" }, + { label: "Non-billable", value: "non-billable" }, +]; diff --git a/apps/web/src/features/time-tracker/pages/calendar-page.tsx b/apps/web/src/features/time-tracker/pages/calendar-page.tsx new file mode 100644 index 0000000..60aac07 --- /dev/null +++ b/apps/web/src/features/time-tracker/pages/calendar-page.tsx @@ -0,0 +1,276 @@ +import type { TrackerEntry } from "@open-learn/api/modules/time-tracker/time-tracker.schema"; +import type { SlotInfo } from "react-big-calendar"; +import type { CalendarBillableFilter } from "../constants/calendar"; +import type { CalendarEntryEvent, CalendarSheetMode, CalendarViewKey } from "../utils/calendar"; + +import { useEffect, useMemo, useState } from "react"; +import { Button } from "@open-learn/ui/components/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@open-learn/ui/components/empty"; +import { Skeleton } from "@open-learn/ui/components/skeleton"; +import { CalendarClockIcon } from "lucide-react"; + +import { useTasksQuery } from "@/features/tasks/services/queries"; +import { CALENDAR_COPY } from "../constants/calendar"; +import { CalendarActiveTimerCard } from "../components/calendar-active-timer-card"; +import { CalendarEntrySheet } from "../components/calendar-entry-sheet"; +import { CalendarToolbar } from "../components/calendar-toolbar"; +import { TrackerCalendar } from "../components/tracker-calendar"; +import { useTrackerOverviewQuery } from "../services/queries"; +import { useUpdateEntry } from "../services/mutations"; +import { + getCalendarRange, + getCalendarViewTitle, + getDefaultSlotEnd, + mapTrackerEntriesToCalendarEvents, + shiftCalendarDate, +} from "../utils/calendar"; + +interface CalendarSheetState { + mode: CalendarSheetMode; + entry: TrackerEntry | null; + selection: { start: Date; end: Date } | null; +} + +export default function CalendarPage() { + const [view, setView] = useState("week"); + const [focusedDate, setFocusedDate] = useState(() => new Date()); + const [projectFilter, setProjectFilter] = useState("all"); + const [billableFilter, setBillableFilter] = useState("all"); + const [sheetState, setSheetState] = useState(null); + const [now, setNow] = useState(() => new Date()); + + const range = useMemo(() => getCalendarRange(view, focusedDate), [focusedDate, view]); + const trackerOverview = useTrackerOverviewQuery(range); + const tasksQuery = useTasksQuery(); + const updateEntry = useUpdateEntry(range); + + useEffect(() => { + if (!trackerOverview.data?.activeEntry) { + return; + } + + const timer = window.setInterval(() => setNow(new Date()), 30_000); + + return () => window.clearInterval(timer); + }, [trackerOverview.data?.activeEntry]); + + const filteredEntries = useMemo(() => { + const entries = trackerOverview.data?.entries ?? []; + + return entries.filter((entry) => { + const matchesProject = + projectFilter === "all" ? true : String(entry.project?.id ?? "") === projectFilter; + const matchesBillable = + billableFilter === "all" + ? true + : billableFilter === "billable" + ? entry.isBillable + : !entry.isBillable; + + return matchesProject && matchesBillable; + }); + }, [billableFilter, projectFilter, trackerOverview.data?.entries]); + + const visibleEvents = useMemo( + () => + mapTrackerEntriesToCalendarEvents({ + entries: filteredEntries, + activeEntry: + trackerOverview.data?.activeEntry && + (projectFilter === "all" || + String(trackerOverview.data.activeEntry.project?.id ?? "") === projectFilter) && + (billableFilter === "all" || + (billableFilter === "billable" + ? trackerOverview.data.activeEntry.isBillable + : !trackerOverview.data.activeEntry.isBillable)) + ? trackerOverview.data.activeEntry + : null, + now, + }), + [billableFilter, filteredEntries, now, projectFilter, trackerOverview.data?.activeEntry], + ); + + const title = useMemo(() => getCalendarViewTitle(view, focusedDate), [focusedDate, view]); + + const handleSelectSlot = (slot: SlotInfo) => { + if (view === "month") { + const start = new Date(slot.start); + start.setHours(9, 0, 0, 0); + const end = new Date(start); + end.setHours(10, 0, 0, 0); + + setSheetState({ + mode: "create", + entry: null, + selection: { start, end }, + }); + return; + } + + const start = new Date(slot.start); + const end = slot.end > slot.start ? new Date(slot.end) : getDefaultSlotEnd(start); + + setSheetState({ + mode: "create", + entry: null, + selection: { start, end }, + }); + }; + + const handleSelectEvent = (event: CalendarEntryEvent) => { + if (!event.resource.canEdit) { + return; + } + + const entry = + trackerOverview.data?.entries.find((item) => item.id === event.resource.entryId) ?? null; + + if (!entry) { + return; + } + + setSheetState({ + mode: "edit", + entry, + selection: null, + }); + }; + + const persistEventTimeChange = async ({ + event, + start, + end, + }: { + event: CalendarEntryEvent; + start: string | Date; + end: string | Date; + }) => { + if (!event.resource.canEdit) { + return; + } + + const entry = trackerOverview.data?.entries.find((item) => item.id === event.resource.entryId); + + if (!entry) { + return; + } + + try { + await updateEntry.mutateAsync({ + entryId: entry.id, + description: entry.description.trim(), + projectId: entry.project?.id ?? null, + taskId: entry.task?.id ?? null, + tagIds: entry.tags.map((tag) => tag.id), + isBillable: entry.isBillable, + startAt: new Date(start).toISOString(), + endAt: new Date(end).toISOString(), + }); + } catch { + // useUpdateEntry handles rollback + user-facing errors in its onError callback. + } + }; + + const isLoading = (trackerOverview.isLoading && !trackerOverview.data) || tasksQuery.isLoading; + + return ( +
+
+

{CALENDAR_COPY.pageTitle}

+

{CALENDAR_COPY.pageDescription}

+
+ + setFocusedDate(new Date())} + onPrevious={() => setFocusedDate((current) => shiftCalendarDate(view, current, -1))} + onNext={() => setFocusedDate((current) => shiftCalendarDate(view, current, 1))} + /> + + {trackerOverview.data?.activeEntry ? ( + + ) : null} + + {isLoading ? ( + + ) : ( +
+ {visibleEvents.length === 0 ? ( + + + + + + {CALENDAR_COPY.emptyTitle} + {CALENDAR_COPY.emptyDescription} + + + + + + ) : null} + + +
+ )} + + { + if (!open) { + setSheetState(null); + } + }} + /> +
+ ); +} diff --git a/apps/web/src/features/time-tracker/services/mutations.ts b/apps/web/src/features/time-tracker/services/mutations.ts index c35aad3..ec7887a 100644 --- a/apps/web/src/features/time-tracker/services/mutations.ts +++ b/apps/web/src/features/time-tracker/services/mutations.ts @@ -1,3 +1,8 @@ +import type { + TrackerEntry, + TrackerOverview, + UpdateEntryInput, +} from "@open-learn/api/modules/time-tracker/time-tracker.schema"; import type { TrackerOverviewRange } from "../utils/date-time"; import { useMutation, useQueryClient } from "@tanstack/react-query"; @@ -5,16 +10,53 @@ import { toast } from "sonner"; import { trpc } from "@/utils/trpc"; -function useInvalidateOverview(range: TrackerOverviewRange) { +function useInvalidateOverview(_range: TrackerOverviewRange) { const queryClient = useQueryClient(); - return () => queryClient.invalidateQueries(trpc.timeTracker.overview.queryOptions(range)); + return () => queryClient.invalidateQueries({ queryKey: trpc.timeTracker.overview.queryKey() }); } function showMutationError(error: { message?: string }) { toast.error(error.message || "Something went wrong"); } +function applyOptimisticEntryUpdate( + overview: TrackerOverview | undefined, + input: UpdateEntryInput, +): TrackerOverview | undefined { + if (!overview) { + return overview; + } + + const nextProject = input.projectId + ? (overview.projects.find((project) => project.id === input.projectId) ?? null) + : null; + const nextTags = overview.tags.filter((tag) => input.tagIds.includes(tag.id)); + + const updateEntry = (entry: TrackerEntry | null) => { + if (!entry || entry.id !== input.entryId) { + return entry; + } + + return { + ...entry, + description: input.description, + isBillable: input.isBillable, + startAt: input.startAt, + endAt: input.endAt, + project: nextProject, + task: input.taskId === null ? null : entry.task?.id === input.taskId ? entry.task : null, + tags: nextTags, + } satisfies TrackerEntry; + }; + + return { + ...overview, + activeEntry: updateEntry(overview.activeEntry), + entries: overview.entries.map((entry) => updateEntry(entry) ?? entry), + }; +} + export function useStartTimer(range: TrackerOverviewRange) { const invalidate = useInvalidateOverview(range); @@ -86,15 +128,40 @@ export function useCreateManualEntry(range: TrackerOverviewRange) { } export function useUpdateEntry(range: TrackerOverviewRange) { + const queryClient = useQueryClient(); const invalidate = useInvalidateOverview(range); return useMutation( trpc.timeTracker.updateEntry.mutationOptions({ + onMutate: async (input) => { + const queryKey = trpc.timeTracker.overview.queryKey(); + + await queryClient.cancelQueries({ queryKey }); + + const previousOverviews = queryClient.getQueriesData({ queryKey }); + + for (const [key, overview] of previousOverviews) { + queryClient.setQueryData( + key, + applyOptimisticEntryUpdate(overview, input), + ); + } + + return { previousOverviews }; + }, onSuccess: () => { toast.success("Entry updated"); + }, + onError: (error, _input, context) => { + for (const [key, overview] of context?.previousOverviews ?? []) { + queryClient.setQueryData(key, overview); + } + + showMutationError(error); + }, + onSettled: () => { invalidate(); }, - onError: showMutationError, }), ); } diff --git a/apps/web/src/features/time-tracker/services/queries.ts b/apps/web/src/features/time-tracker/services/queries.ts index 7f35b02..2b22ce4 100644 --- a/apps/web/src/features/time-tracker/services/queries.ts +++ b/apps/web/src/features/time-tracker/services/queries.ts @@ -5,5 +5,8 @@ import { useQuery } from "@tanstack/react-query"; import { trpc } from "@/utils/trpc"; export function useTrackerOverviewQuery(range: TrackerOverviewRange) { - return useQuery(trpc.timeTracker.overview.queryOptions(range)); + return useQuery({ + ...trpc.timeTracker.overview.queryOptions(range), + placeholderData: (previousData) => previousData, + }); } diff --git a/apps/web/src/features/time-tracker/styles/react-big-calendar.css b/apps/web/src/features/time-tracker/styles/react-big-calendar.css new file mode 100644 index 0000000..ae6afe0 --- /dev/null +++ b/apps/web/src/features/time-tracker/styles/react-big-calendar.css @@ -0,0 +1,170 @@ +.open-clock-calendar { + --calendar-billable: var(--color-billable); + --calendar-tracked: var(--color-tracked); + --calendar-active: color-mix(in srgb, var(--foreground) 12%, transparent); + --calendar-grid: color-mix(in srgb, var(--border) 90%, transparent); +} + +.open-clock-calendar .rbc-calendar, +.open-clock-calendar .rbc-time-view, +.open-clock-calendar .rbc-month-view, +.open-clock-calendar .rbc-header, +.open-clock-calendar .rbc-time-content, +.open-clock-calendar .rbc-time-header-content, +.open-clock-calendar .rbc-time-header, +.open-clock-calendar .rbc-day-bg, +.open-clock-calendar .rbc-timeslot-group, +.open-clock-calendar .rbc-time-slot, +.open-clock-calendar .rbc-show-more, +.open-clock-calendar .rbc-overlay, +.open-clock-calendar .rbc-event, +.open-clock-calendar .rbc-selected-cell { + border-radius: 0; +} + +.open-clock-calendar .rbc-calendar { + height: 100%; + background: var(--background); + color: var(--foreground); + font-size: 12px; +} + +.open-clock-calendar .rbc-time-view, +.open-clock-calendar .rbc-month-view { + border: 0; +} + +.open-clock-calendar .rbc-month-row + .rbc-month-row, +.open-clock-calendar .rbc-time-content, +.open-clock-calendar .rbc-timeslot-group, +.open-clock-calendar .rbc-day-bg, +.open-clock-calendar .rbc-header, +.open-clock-calendar .rbc-time-header-content, +.open-clock-calendar .rbc-time-content > * + * > *, +.open-clock-calendar .rbc-agenda-view table.rbc-agenda-table tbody > tr + tr { + border-color: var(--calendar-grid); +} + +.open-clock-calendar .rbc-header { + border-bottom: 1px solid var(--calendar-grid); + border-right: 1px solid var(--calendar-grid); + padding: 0.75rem 0.5rem; + text-align: center; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--muted-foreground); + background: color-mix(in srgb, var(--card) 92%, transparent); +} + +.open-clock-calendar .rbc-time-header, +.open-clock-calendar .rbc-time-gutter { + background: color-mix(in srgb, var(--card) 96%, transparent); +} + +.open-clock-calendar .rbc-time-gutter .rbc-timeslot-group, +.open-clock-calendar .rbc-time-gutter .rbc-time-slot { + font-variant-numeric: tabular-nums; + color: var(--muted-foreground); +} + +.open-clock-calendar .rbc-time-gutter .rbc-label { + padding-right: 0.75rem; +} + +.open-clock-calendar .rbc-day-slot .rbc-time-slot, +.open-clock-calendar .rbc-time-content { + border-top-color: color-mix(in srgb, var(--border) 65%, transparent); +} + +.open-clock-calendar .rbc-day-bg { + background: var(--background); +} + +.open-clock-calendar .rbc-off-range-bg { + background: color-mix(in srgb, var(--muted) 55%, transparent); +} + +.open-clock-calendar .rbc-off-range { + color: var(--muted-foreground); +} + +.open-clock-calendar .rbc-today { + background: color-mix(in srgb, var(--muted) 30%, transparent); +} + +.open-clock-calendar .rbc-current-time-indicator { + border-top: 1px solid var(--calendar-billable); + background: var(--calendar-billable); + height: 1px; +} + +.open-clock-calendar .rbc-event, +.open-clock-calendar .rbc-day-slot .rbc-event { + border: 1px solid transparent; + background: color-mix(in srgb, var(--muted) 45%, var(--background)); + color: var(--foreground); + box-shadow: none; + padding: 0; +} + +.open-clock-calendar .rbc-event:focus, +.open-clock-calendar .rbc-event:active, +.open-clock-calendar .rbc-event.rbc-selected { + outline: none; + box-shadow: inset 0 0 0 1px var(--ring); +} + +.open-clock-calendar .rbc-event.calendar-entry-event--billable { + background: color-mix(in srgb, var(--calendar-billable) 10%, var(--background)); + border-color: color-mix(in srgb, var(--calendar-billable) 35%, transparent); +} + +.open-clock-calendar .rbc-event.calendar-entry-event--tracked { + background: color-mix(in srgb, var(--calendar-tracked) 10%, var(--background)); + border-color: color-mix(in srgb, var(--calendar-tracked) 35%, transparent); +} + +.open-clock-calendar .rbc-event.calendar-entry-event--active { + background: color-mix(in srgb, var(--calendar-active) 75%, var(--background)); + border-color: color-mix(in srgb, var(--foreground) 18%, transparent); +} + +.open-clock-calendar .rbc-show-more { + background: var(--muted); + border: 1px solid var(--calendar-grid); + color: var(--foreground); + padding: 0.125rem 0.375rem; + font-size: 11px; +} + +.open-clock-calendar .rbc-overlay { + background: var(--popover); + color: var(--popover-foreground); + border: 1px solid var(--border); + box-shadow: none; + ring: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); +} + +.open-clock-calendar .rbc-overlay-header { + border-bottom: 1px solid var(--calendar-grid); + padding-bottom: 0.5rem; + margin-bottom: 0.5rem; +} + +.open-clock-calendar .rbc-month-row, +.open-clock-calendar .rbc-time-content, +.open-clock-calendar .rbc-time-header { + min-height: 0; +} + +.open-clock-calendar .rbc-month-view .rbc-date-cell { + padding: 0.35rem 0.5rem; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.open-clock-calendar .rbc-agenda-empty { + color: var(--muted-foreground); +} diff --git a/apps/web/src/features/time-tracker/utils/calendar.ts b/apps/web/src/features/time-tracker/utils/calendar.ts new file mode 100644 index 0000000..7a56e9c --- /dev/null +++ b/apps/web/src/features/time-tracker/utils/calendar.ts @@ -0,0 +1,215 @@ +import type { TrackerEntry } from "@open-learn/api/modules/time-tracker/time-tracker.schema"; +import type { Formats } from "react-big-calendar"; +import type { TrackerOverviewRange } from "./date-time"; + +import { + addDays, + addMonths, + addWeeks, + endOfMonth, + endOfWeek, + format, + getDay, + parse, + startOfDay, + startOfMonth, + startOfWeek as startOfWeekDateFns, +} from "date-fns"; +import { enUS } from "date-fns/locale"; +import { dateFnsLocalizer } from "react-big-calendar"; + +import { getElapsedSeconds, getEntryDurationSeconds, startOfWeek } from "./date-time"; + +export type CalendarViewKey = "week" | "day" | "month"; +export type CalendarSheetMode = "create" | "edit"; + +export interface CalendarEntryEventResource { + entryId: number; + description: string; + projectName: string; + projectId: number | null; + taskTitle: string | null; + taskDisplayKey: string | null; + tagNames: string[]; + isBillable: boolean; + isActive: boolean; + durationSeconds: number; + canEdit: boolean; +} + +export interface CalendarEntryEvent { + id: string; + title: string; + start: Date; + end: Date; + allDay?: boolean; + resource: CalendarEntryEventResource; +} + +const locales = { + en: enUS, +}; + +export const calendarLocalizer = dateFnsLocalizer({ + format, + parse, + startOfWeek: (date: Date) => startOfWeekDateFns(date, { weekStartsOn: 1 }), + getDay, + locales, +}); + +export const CALENDAR_FORMATS: Partial = { + monthHeaderFormat: "MMMM yyyy", + dayHeaderFormat: "EEEE, MMM d", + dayRangeHeaderFormat: ({ start, end }) => `${format(start, "MMM d")} – ${format(end, "MMM d")}`, + weekdayFormat: "EEE", + dayFormat: "EEE d", + agendaDateFormat: "EEE, MMM d", + agendaTimeFormat: "HH:mm", + timeGutterFormat: "HH:mm", +}; + +function getEventTitle(entry: TrackerEntry) { + const trimmedDescription = entry.description.trim(); + + if (trimmedDescription) { + return trimmedDescription; + } + + if (entry.task?.title) { + return entry.task.title; + } + + return "No description"; +} + +function toCalendarEvent(entry: TrackerEntry, options?: { isActive?: boolean; now?: Date }) { + const isActive = options?.isActive ?? false; + const now = options?.now ?? new Date(); + const start = new Date(entry.startAt); + const end = new Date(entry.endAt ?? now.toISOString()); + + return { + id: `${isActive ? "active" : "entry"}-${entry.id}`, + title: getEventTitle(entry), + start, + end, + resource: { + entryId: entry.id, + description: entry.description, + projectName: entry.project?.name ?? "Without project", + projectId: entry.project?.id ?? null, + taskTitle: entry.task?.title ?? null, + taskDisplayKey: entry.task?.displayKey ?? null, + tagNames: entry.tags.map((tag) => tag.name), + isBillable: entry.isBillable, + isActive, + durationSeconds: isActive + ? getElapsedSeconds(entry.startAt, now) + : getEntryDurationSeconds(entry), + canEdit: !isActive && entry.endAt !== null, + }, + } satisfies CalendarEntryEvent; +} + +export function mapTrackerEntriesToCalendarEvents({ + entries, + activeEntry, + now = new Date(), +}: { + entries: TrackerEntry[]; + activeEntry?: TrackerEntry | null; + now?: Date; +}) { + const mappedEntries = entries.map((entry) => toCalendarEvent(entry, { now })); + + if (!activeEntry) { + return mappedEntries; + } + + return [...mappedEntries, toCalendarEvent(activeEntry, { isActive: true, now })]; +} + +export function getCalendarRange(view: CalendarViewKey, focusedDate: Date): TrackerOverviewRange { + if (view === "day") { + const from = startOfDay(focusedDate); + const to = addDays(from, 1); + + return { + from: from.toISOString(), + to: to.toISOString(), + }; + } + + if (view === "week") { + const from = startOfWeek(focusedDate); + const to = addDays(from, 7); + + return { + from: from.toISOString(), + to: to.toISOString(), + }; + } + + const firstVisibleDay = startOfWeekDateFns(startOfMonth(focusedDate), { weekStartsOn: 1 }); + const lastVisibleDay = endOfWeek(endOfMonth(focusedDate), { weekStartsOn: 1 }); + + return { + from: firstVisibleDay.toISOString(), + to: addDays(startOfDay(lastVisibleDay), 1).toISOString(), + }; +} + +export function getCalendarViewTitle(view: CalendarViewKey, focusedDate: Date) { + if (view === "day") { + return format(focusedDate, "EEEE, MMMM d"); + } + + if (view === "week") { + const start = startOfWeek(focusedDate); + const end = addDays(start, 6); + + return `${format(start, "MMM d")} – ${format(end, "MMM d")}`; + } + + return format(focusedDate, "MMMM yyyy"); +} + +export function getDefaultSlotEnd(start: Date) { + const end = new Date(start); + end.setHours(end.getHours() + 1, start.getMinutes(), 0, 0); + return end; +} + +export function shiftCalendarDate(view: CalendarViewKey, focusedDate: Date, direction: -1 | 1) { + if (view === "day") { + return addDays(focusedDate, direction); + } + + if (view === "week") { + return addWeeks(focusedDate, direction); + } + + return addMonths(focusedDate, direction); +} + +export function getCalendarEventClassName(event: CalendarEntryEvent) { + if (event.resource.isActive) { + return "calendar-entry-event calendar-entry-event--active"; + } + + return event.resource.isBillable + ? "calendar-entry-event calendar-entry-event--billable" + : "calendar-entry-event calendar-entry-event--tracked"; +} + +export function formatCalendarEventDuration(totalSeconds: number) { + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + + if (hours === 0) { + return `${String(minutes).padStart(2, "0")}m`; + } + + return `${hours}h ${String(minutes).padStart(2, "0")}m`; +} diff --git a/apps/web/src/hooks/use-mobile.ts b/apps/web/src/hooks/use-mobile.ts new file mode 100644 index 0000000..502fd32 --- /dev/null +++ b/apps/web/src/hooks/use-mobile.ts @@ -0,0 +1,19 @@ +import * as React from "react"; + +const MOBILE_BREAKPOINT = 768; + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined); + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + }; + mql.addEventListener("change", onChange); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + return () => mql.removeEventListener("change", onChange); + }, []); + + return !!isMobile; +} diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts new file mode 100644 index 0000000..a5ef193 --- /dev/null +++ b/apps/web/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 7339bce..693ac27 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -11,18 +11,19 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SuccessRouteImport } from './routes/success' import { Route as LoginRouteImport } from './routes/login' -import { Route as HomeRouteImport } from './routes/home' -import { Route as AppRouteImport } from './routes/_app' -import { Route as AppIndexRouteImport } from './routes/_app.index' +import { Route as IndexRouteImport } from './routes/index' +import { Route as AppAppRouteImport } from './routes/app._app' import { Route as AcceptInvitationInvitationIdRouteImport } from './routes/accept-invitation.$invitationId' -import { Route as AppTrackerRouteImport } from './routes/_app.tracker' -import { Route as AppTeamsRouteImport } from './routes/_app.teams' -import { Route as AppTasksRouteImport } from './routes/_app.tasks' -import { Route as AppTagsRouteImport } from './routes/_app.tags' -import { Route as AppReportsRouteImport } from './routes/_app.reports' -import { Route as AppProjectsRouteImport } from './routes/_app.projects' -import { Route as AppClientsRouteImport } from './routes/_app.clients' -import { Route as AppAiRouteImport } from './routes/_app.ai' +import { Route as AppAppIndexRouteImport } from './routes/app._app.index' +import { Route as AppAppTrackerRouteImport } from './routes/app._app.tracker' +import { Route as AppAppTeamsRouteImport } from './routes/app._app.teams' +import { Route as AppAppTasksRouteImport } from './routes/app._app.tasks' +import { Route as AppAppTagsRouteImport } from './routes/app._app.tags' +import { Route as AppAppReportsRouteImport } from './routes/app._app.reports' +import { Route as AppAppProjectsRouteImport } from './routes/app._app.projects' +import { Route as AppAppClientsRouteImport } from './routes/app._app.clients' +import { Route as AppAppCalendarRouteImport } from './routes/app._app.calendar' +import { Route as AppAppAiRouteImport } from './routes/app._app.ai' const SuccessRoute = SuccessRouteImport.update({ id: '/success', @@ -34,169 +35,183 @@ const LoginRoute = LoginRouteImport.update({ path: '/login', getParentRoute: () => rootRouteImport, } as any) -const HomeRoute = HomeRouteImport.update({ - id: '/home', - path: '/home', +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', getParentRoute: () => rootRouteImport, } as any) -const AppRoute = AppRouteImport.update({ - id: '/_app', +const AppAppRoute = AppAppRouteImport.update({ + id: '/app/_app', + path: '/app', getParentRoute: () => rootRouteImport, } as any) -const AppIndexRoute = AppIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => AppRoute, -} as any) const AcceptInvitationInvitationIdRoute = AcceptInvitationInvitationIdRouteImport.update({ id: '/accept-invitation/$invitationId', path: '/accept-invitation/$invitationId', getParentRoute: () => rootRouteImport, } as any) -const AppTrackerRoute = AppTrackerRouteImport.update({ +const AppAppIndexRoute = AppAppIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AppAppRoute, +} as any) +const AppAppTrackerRoute = AppAppTrackerRouteImport.update({ id: '/tracker', path: '/tracker', - getParentRoute: () => AppRoute, + getParentRoute: () => AppAppRoute, } as any) -const AppTeamsRoute = AppTeamsRouteImport.update({ +const AppAppTeamsRoute = AppAppTeamsRouteImport.update({ id: '/teams', path: '/teams', - getParentRoute: () => AppRoute, + getParentRoute: () => AppAppRoute, } as any) -const AppTasksRoute = AppTasksRouteImport.update({ +const AppAppTasksRoute = AppAppTasksRouteImport.update({ id: '/tasks', path: '/tasks', - getParentRoute: () => AppRoute, + getParentRoute: () => AppAppRoute, } as any) -const AppTagsRoute = AppTagsRouteImport.update({ +const AppAppTagsRoute = AppAppTagsRouteImport.update({ id: '/tags', path: '/tags', - getParentRoute: () => AppRoute, + getParentRoute: () => AppAppRoute, } as any) -const AppReportsRoute = AppReportsRouteImport.update({ +const AppAppReportsRoute = AppAppReportsRouteImport.update({ id: '/reports', path: '/reports', - getParentRoute: () => AppRoute, + getParentRoute: () => AppAppRoute, } as any) -const AppProjectsRoute = AppProjectsRouteImport.update({ +const AppAppProjectsRoute = AppAppProjectsRouteImport.update({ id: '/projects', path: '/projects', - getParentRoute: () => AppRoute, + getParentRoute: () => AppAppRoute, } as any) -const AppClientsRoute = AppClientsRouteImport.update({ +const AppAppClientsRoute = AppAppClientsRouteImport.update({ id: '/clients', path: '/clients', - getParentRoute: () => AppRoute, + getParentRoute: () => AppAppRoute, +} as any) +const AppAppCalendarRoute = AppAppCalendarRouteImport.update({ + id: '/calendar', + path: '/calendar', + getParentRoute: () => AppAppRoute, } as any) -const AppAiRoute = AppAiRouteImport.update({ +const AppAppAiRoute = AppAppAiRouteImport.update({ id: '/ai', path: '/ai', - getParentRoute: () => AppRoute, + getParentRoute: () => AppAppRoute, } as any) export interface FileRoutesByFullPath { - '/': typeof AppIndexRoute - '/home': typeof HomeRoute + '/': typeof IndexRoute '/login': typeof LoginRoute '/success': typeof SuccessRoute - '/ai': typeof AppAiRoute - '/clients': typeof AppClientsRoute - '/projects': typeof AppProjectsRoute - '/reports': typeof AppReportsRoute - '/tags': typeof AppTagsRoute - '/tasks': typeof AppTasksRoute - '/teams': typeof AppTeamsRoute - '/tracker': typeof AppTrackerRoute '/accept-invitation/$invitationId': typeof AcceptInvitationInvitationIdRoute + '/app': typeof AppAppRouteWithChildren + '/app/ai': typeof AppAppAiRoute + '/app/calendar': typeof AppAppCalendarRoute + '/app/clients': typeof AppAppClientsRoute + '/app/projects': typeof AppAppProjectsRoute + '/app/reports': typeof AppAppReportsRoute + '/app/tags': typeof AppAppTagsRoute + '/app/tasks': typeof AppAppTasksRoute + '/app/teams': typeof AppAppTeamsRoute + '/app/tracker': typeof AppAppTrackerRoute + '/app/': typeof AppAppIndexRoute } export interface FileRoutesByTo { - '/home': typeof HomeRoute + '/': typeof IndexRoute '/login': typeof LoginRoute '/success': typeof SuccessRoute - '/ai': typeof AppAiRoute - '/clients': typeof AppClientsRoute - '/projects': typeof AppProjectsRoute - '/reports': typeof AppReportsRoute - '/tags': typeof AppTagsRoute - '/tasks': typeof AppTasksRoute - '/teams': typeof AppTeamsRoute - '/tracker': typeof AppTrackerRoute '/accept-invitation/$invitationId': typeof AcceptInvitationInvitationIdRoute - '/': typeof AppIndexRoute + '/app/ai': typeof AppAppAiRoute + '/app/calendar': typeof AppAppCalendarRoute + '/app/clients': typeof AppAppClientsRoute + '/app/projects': typeof AppAppProjectsRoute + '/app/reports': typeof AppAppReportsRoute + '/app/tags': typeof AppAppTagsRoute + '/app/tasks': typeof AppAppTasksRoute + '/app/teams': typeof AppAppTeamsRoute + '/app/tracker': typeof AppAppTrackerRoute + '/app': typeof AppAppIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport - '/_app': typeof AppRouteWithChildren - '/home': typeof HomeRoute + '/': typeof IndexRoute '/login': typeof LoginRoute '/success': typeof SuccessRoute - '/_app/ai': typeof AppAiRoute - '/_app/clients': typeof AppClientsRoute - '/_app/projects': typeof AppProjectsRoute - '/_app/reports': typeof AppReportsRoute - '/_app/tags': typeof AppTagsRoute - '/_app/tasks': typeof AppTasksRoute - '/_app/teams': typeof AppTeamsRoute - '/_app/tracker': typeof AppTrackerRoute '/accept-invitation/$invitationId': typeof AcceptInvitationInvitationIdRoute - '/_app/': typeof AppIndexRoute + '/app/_app': typeof AppAppRouteWithChildren + '/app/_app/ai': typeof AppAppAiRoute + '/app/_app/calendar': typeof AppAppCalendarRoute + '/app/_app/clients': typeof AppAppClientsRoute + '/app/_app/projects': typeof AppAppProjectsRoute + '/app/_app/reports': typeof AppAppReportsRoute + '/app/_app/tags': typeof AppAppTagsRoute + '/app/_app/tasks': typeof AppAppTasksRoute + '/app/_app/teams': typeof AppAppTeamsRoute + '/app/_app/tracker': typeof AppAppTrackerRoute + '/app/_app/': typeof AppAppIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' - | '/home' | '/login' | '/success' - | '/ai' - | '/clients' - | '/projects' - | '/reports' - | '/tags' - | '/tasks' - | '/teams' - | '/tracker' | '/accept-invitation/$invitationId' + | '/app' + | '/app/ai' + | '/app/calendar' + | '/app/clients' + | '/app/projects' + | '/app/reports' + | '/app/tags' + | '/app/tasks' + | '/app/teams' + | '/app/tracker' + | '/app/' fileRoutesByTo: FileRoutesByTo to: - | '/home' + | '/' | '/login' | '/success' - | '/ai' - | '/clients' - | '/projects' - | '/reports' - | '/tags' - | '/tasks' - | '/teams' - | '/tracker' | '/accept-invitation/$invitationId' - | '/' + | '/app/ai' + | '/app/calendar' + | '/app/clients' + | '/app/projects' + | '/app/reports' + | '/app/tags' + | '/app/tasks' + | '/app/teams' + | '/app/tracker' + | '/app' id: | '__root__' - | '/_app' - | '/home' + | '/' | '/login' | '/success' - | '/_app/ai' - | '/_app/clients' - | '/_app/projects' - | '/_app/reports' - | '/_app/tags' - | '/_app/tasks' - | '/_app/teams' - | '/_app/tracker' | '/accept-invitation/$invitationId' - | '/_app/' + | '/app/_app' + | '/app/_app/ai' + | '/app/_app/calendar' + | '/app/_app/clients' + | '/app/_app/projects' + | '/app/_app/reports' + | '/app/_app/tags' + | '/app/_app/tasks' + | '/app/_app/teams' + | '/app/_app/tracker' + | '/app/_app/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { - AppRoute: typeof AppRouteWithChildren - HomeRoute: typeof HomeRoute + IndexRoute: typeof IndexRoute LoginRoute: typeof LoginRoute SuccessRoute: typeof SuccessRoute AcceptInvitationInvitationIdRoute: typeof AcceptInvitationInvitationIdRoute + AppAppRoute: typeof AppAppRouteWithChildren } declare module '@tanstack/react-router' { @@ -215,26 +230,19 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginRouteImport parentRoute: typeof rootRouteImport } - '/home': { - id: '/home' - path: '/home' - fullPath: '/home' - preLoaderRoute: typeof HomeRouteImport - parentRoute: typeof rootRouteImport - } - '/_app': { - id: '/_app' - path: '' + '/': { + id: '/' + path: '/' fullPath: '/' - preLoaderRoute: typeof AppRouteImport + preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } - '/_app/': { - id: '/_app/' - path: '/' - fullPath: '/' - preLoaderRoute: typeof AppIndexRouteImport - parentRoute: typeof AppRoute + '/app/_app': { + id: '/app/_app' + path: '/app' + fullPath: '/app' + preLoaderRoute: typeof AppAppRouteImport + parentRoute: typeof rootRouteImport } '/accept-invitation/$invitationId': { id: '/accept-invitation/$invitationId' @@ -243,97 +251,114 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AcceptInvitationInvitationIdRouteImport parentRoute: typeof rootRouteImport } - '/_app/tracker': { - id: '/_app/tracker' + '/app/_app/': { + id: '/app/_app/' + path: '/' + fullPath: '/app/' + preLoaderRoute: typeof AppAppIndexRouteImport + parentRoute: typeof AppAppRoute + } + '/app/_app/tracker': { + id: '/app/_app/tracker' path: '/tracker' - fullPath: '/tracker' - preLoaderRoute: typeof AppTrackerRouteImport - parentRoute: typeof AppRoute + fullPath: '/app/tracker' + preLoaderRoute: typeof AppAppTrackerRouteImport + parentRoute: typeof AppAppRoute } - '/_app/teams': { - id: '/_app/teams' + '/app/_app/teams': { + id: '/app/_app/teams' path: '/teams' - fullPath: '/teams' - preLoaderRoute: typeof AppTeamsRouteImport - parentRoute: typeof AppRoute + fullPath: '/app/teams' + preLoaderRoute: typeof AppAppTeamsRouteImport + parentRoute: typeof AppAppRoute } - '/_app/tasks': { - id: '/_app/tasks' + '/app/_app/tasks': { + id: '/app/_app/tasks' path: '/tasks' - fullPath: '/tasks' - preLoaderRoute: typeof AppTasksRouteImport - parentRoute: typeof AppRoute + fullPath: '/app/tasks' + preLoaderRoute: typeof AppAppTasksRouteImport + parentRoute: typeof AppAppRoute } - '/_app/tags': { - id: '/_app/tags' + '/app/_app/tags': { + id: '/app/_app/tags' path: '/tags' - fullPath: '/tags' - preLoaderRoute: typeof AppTagsRouteImport - parentRoute: typeof AppRoute + fullPath: '/app/tags' + preLoaderRoute: typeof AppAppTagsRouteImport + parentRoute: typeof AppAppRoute } - '/_app/reports': { - id: '/_app/reports' + '/app/_app/reports': { + id: '/app/_app/reports' path: '/reports' - fullPath: '/reports' - preLoaderRoute: typeof AppReportsRouteImport - parentRoute: typeof AppRoute + fullPath: '/app/reports' + preLoaderRoute: typeof AppAppReportsRouteImport + parentRoute: typeof AppAppRoute } - '/_app/projects': { - id: '/_app/projects' + '/app/_app/projects': { + id: '/app/_app/projects' path: '/projects' - fullPath: '/projects' - preLoaderRoute: typeof AppProjectsRouteImport - parentRoute: typeof AppRoute + fullPath: '/app/projects' + preLoaderRoute: typeof AppAppProjectsRouteImport + parentRoute: typeof AppAppRoute } - '/_app/clients': { - id: '/_app/clients' + '/app/_app/clients': { + id: '/app/_app/clients' path: '/clients' - fullPath: '/clients' - preLoaderRoute: typeof AppClientsRouteImport - parentRoute: typeof AppRoute + fullPath: '/app/clients' + preLoaderRoute: typeof AppAppClientsRouteImport + parentRoute: typeof AppAppRoute + } + '/app/_app/calendar': { + id: '/app/_app/calendar' + path: '/calendar' + fullPath: '/app/calendar' + preLoaderRoute: typeof AppAppCalendarRouteImport + parentRoute: typeof AppAppRoute } - '/_app/ai': { - id: '/_app/ai' + '/app/_app/ai': { + id: '/app/_app/ai' path: '/ai' - fullPath: '/ai' - preLoaderRoute: typeof AppAiRouteImport - parentRoute: typeof AppRoute + fullPath: '/app/ai' + preLoaderRoute: typeof AppAppAiRouteImport + parentRoute: typeof AppAppRoute } } } -interface AppRouteChildren { - AppAiRoute: typeof AppAiRoute - AppClientsRoute: typeof AppClientsRoute - AppProjectsRoute: typeof AppProjectsRoute - AppReportsRoute: typeof AppReportsRoute - AppTagsRoute: typeof AppTagsRoute - AppTasksRoute: typeof AppTasksRoute - AppTeamsRoute: typeof AppTeamsRoute - AppTrackerRoute: typeof AppTrackerRoute - AppIndexRoute: typeof AppIndexRoute +interface AppAppRouteChildren { + AppAppAiRoute: typeof AppAppAiRoute + AppAppCalendarRoute: typeof AppAppCalendarRoute + AppAppClientsRoute: typeof AppAppClientsRoute + AppAppProjectsRoute: typeof AppAppProjectsRoute + AppAppReportsRoute: typeof AppAppReportsRoute + AppAppTagsRoute: typeof AppAppTagsRoute + AppAppTasksRoute: typeof AppAppTasksRoute + AppAppTeamsRoute: typeof AppAppTeamsRoute + AppAppTrackerRoute: typeof AppAppTrackerRoute + AppAppIndexRoute: typeof AppAppIndexRoute } -const AppRouteChildren: AppRouteChildren = { - AppAiRoute: AppAiRoute, - AppClientsRoute: AppClientsRoute, - AppProjectsRoute: AppProjectsRoute, - AppReportsRoute: AppReportsRoute, - AppTagsRoute: AppTagsRoute, - AppTasksRoute: AppTasksRoute, - AppTeamsRoute: AppTeamsRoute, - AppTrackerRoute: AppTrackerRoute, - AppIndexRoute: AppIndexRoute, +const AppAppRouteChildren: AppAppRouteChildren = { + AppAppAiRoute: AppAppAiRoute, + AppAppCalendarRoute: AppAppCalendarRoute, + AppAppClientsRoute: AppAppClientsRoute, + AppAppProjectsRoute: AppAppProjectsRoute, + AppAppReportsRoute: AppAppReportsRoute, + AppAppTagsRoute: AppAppTagsRoute, + AppAppTasksRoute: AppAppTasksRoute, + AppAppTeamsRoute: AppAppTeamsRoute, + AppAppTrackerRoute: AppAppTrackerRoute, + AppAppIndexRoute: AppAppIndexRoute, } -const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren) +const AppAppRouteWithChildren = + AppAppRoute._addFileChildren(AppAppRouteChildren) const rootRouteChildren: RootRouteChildren = { - AppRoute: AppRouteWithChildren, - HomeRoute: HomeRoute, + IndexRoute: IndexRoute, LoginRoute: LoginRoute, SuccessRoute: SuccessRoute, AcceptInvitationInvitationIdRoute: AcceptInvitationInvitationIdRoute, + AppAppRoute: AppAppRouteWithChildren, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/_app.ai.tsx b/apps/web/src/routes/app._app.ai.tsx similarity index 71% rename from apps/web/src/routes/_app.ai.tsx rename to apps/web/src/routes/app._app.ai.tsx index ad56120..62c0e5b 100644 --- a/apps/web/src/routes/_app.ai.tsx +++ b/apps/web/src/routes/app._app.ai.tsx @@ -2,6 +2,6 @@ import { createFileRoute } from "@tanstack/react-router"; import AiPage from "@/features/ai/pages/ai-page"; -export const Route = createFileRoute("/_app/ai")({ +export const Route = createFileRoute("/app/_app/ai")({ component: AiPage, }); diff --git a/apps/web/src/routes/app._app.calendar.tsx b/apps/web/src/routes/app._app.calendar.tsx new file mode 100644 index 0000000..ba59667 --- /dev/null +++ b/apps/web/src/routes/app._app.calendar.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import CalendarPage from "@/features/time-tracker/pages/calendar-page"; + +export const Route = createFileRoute("/app/_app/calendar")({ + component: CalendarRoute, +}); + +function CalendarRoute() { + return ; +} diff --git a/apps/web/src/routes/_app.clients.tsx b/apps/web/src/routes/app._app.clients.tsx similarity index 71% rename from apps/web/src/routes/_app.clients.tsx rename to apps/web/src/routes/app._app.clients.tsx index 12490d6..f1fe351 100644 --- a/apps/web/src/routes/_app.clients.tsx +++ b/apps/web/src/routes/app._app.clients.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import ClientsPage from "@/features/clients/pages/clients-page"; -export const Route = createFileRoute("/_app/clients")({ +export const Route = createFileRoute("/app/_app/clients")({ component: ClientsPage, }); diff --git a/apps/web/src/routes/_app.index.tsx b/apps/web/src/routes/app._app.index.tsx similarity index 78% rename from apps/web/src/routes/_app.index.tsx rename to apps/web/src/routes/app._app.index.tsx index 0d9132b..e7effc1 100644 --- a/apps/web/src/routes/_app.index.tsx +++ b/apps/web/src/routes/app._app.index.tsx @@ -2,6 +2,6 @@ import { createFileRoute } from "@tanstack/react-router"; import TrackerDashboardPage from "@/features/time-tracker/pages/tracker-dashboard-page"; -export const Route = createFileRoute("/_app/")({ +export const Route = createFileRoute("/app/_app/")({ component: TrackerDashboardPage, }); diff --git a/apps/web/src/routes/_app.projects.tsx b/apps/web/src/routes/app._app.projects.tsx similarity index 72% rename from apps/web/src/routes/_app.projects.tsx rename to apps/web/src/routes/app._app.projects.tsx index 0a11ed9..6c07dff 100644 --- a/apps/web/src/routes/_app.projects.tsx +++ b/apps/web/src/routes/app._app.projects.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import ProjectsPage from "@/features/projects/pages/projects-page"; -export const Route = createFileRoute("/_app/projects")({ +export const Route = createFileRoute("/app/_app/projects")({ component: ProjectsPage, }); diff --git a/apps/web/src/routes/_app.reports.tsx b/apps/web/src/routes/app._app.reports.tsx similarity index 72% rename from apps/web/src/routes/_app.reports.tsx rename to apps/web/src/routes/app._app.reports.tsx index 8e9f34f..a23dccf 100644 --- a/apps/web/src/routes/_app.reports.tsx +++ b/apps/web/src/routes/app._app.reports.tsx @@ -2,6 +2,6 @@ import { createFileRoute } from "@tanstack/react-router"; import ReportsPage from "@/features/time-tracker/pages/reports-page"; -export const Route = createFileRoute("/_app/reports")({ +export const Route = createFileRoute("/app/_app/reports")({ component: ReportsPage, }); diff --git a/apps/web/src/routes/_app.tags.tsx b/apps/web/src/routes/app._app.tags.tsx similarity index 71% rename from apps/web/src/routes/_app.tags.tsx rename to apps/web/src/routes/app._app.tags.tsx index 3151dfa..62e7053 100644 --- a/apps/web/src/routes/_app.tags.tsx +++ b/apps/web/src/routes/app._app.tags.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import TagsPage from "@/features/tags/pages/tags-page"; -export const Route = createFileRoute("/_app/tags")({ +export const Route = createFileRoute("/app/_app/tags")({ component: TagsPage, }); diff --git a/apps/web/src/routes/_app.tasks.tsx b/apps/web/src/routes/app._app.tasks.tsx similarity index 71% rename from apps/web/src/routes/_app.tasks.tsx rename to apps/web/src/routes/app._app.tasks.tsx index 0b5a142..3eec9bd 100644 --- a/apps/web/src/routes/_app.tasks.tsx +++ b/apps/web/src/routes/app._app.tasks.tsx @@ -1,5 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; import { TasksPage } from "@/features/tasks/pages/tasks-page"; -export const Route = createFileRoute("/_app/tasks")({ +export const Route = createFileRoute("/app/_app/tasks")({ component: TasksPage, }); diff --git a/apps/web/src/routes/_app.teams.tsx b/apps/web/src/routes/app._app.teams.tsx similarity index 72% rename from apps/web/src/routes/_app.teams.tsx rename to apps/web/src/routes/app._app.teams.tsx index 21219e9..09bfd93 100644 --- a/apps/web/src/routes/_app.teams.tsx +++ b/apps/web/src/routes/app._app.teams.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import TeamsPage from "@/features/organization/pages/teams-page"; -export const Route = createFileRoute("/_app/teams")({ +export const Route = createFileRoute("/app/_app/teams")({ component: TeamsPage, }); diff --git a/apps/web/src/routes/_app.tracker.tsx b/apps/web/src/routes/app._app.tracker.tsx similarity index 74% rename from apps/web/src/routes/_app.tracker.tsx rename to apps/web/src/routes/app._app.tracker.tsx index 1ea4481..ea56f70 100644 --- a/apps/web/src/routes/_app.tracker.tsx +++ b/apps/web/src/routes/app._app.tracker.tsx @@ -2,6 +2,6 @@ import { createFileRoute } from "@tanstack/react-router"; import TimeTrackerPage from "@/features/time-tracker/pages/time-tracker-page"; -export const Route = createFileRoute("/_app/tracker")({ +export const Route = createFileRoute("/app/_app/tracker")({ component: TimeTrackerPage, }); diff --git a/apps/web/src/routes/_app.tsx b/apps/web/src/routes/app._app.tsx similarity index 93% rename from apps/web/src/routes/_app.tsx rename to apps/web/src/routes/app._app.tsx index 9d03b38..f76eacf 100644 --- a/apps/web/src/routes/_app.tsx +++ b/apps/web/src/routes/app._app.tsx @@ -6,7 +6,7 @@ import { } from "@/features/navigation/components/app-layout-shell"; import { Outlet, createFileRoute } from "@tanstack/react-router"; -export const Route = createFileRoute("/_app")({ +export const Route = createFileRoute("/app/_app")({ beforeLoad: async () => { const authCtx = await requireAuthBeforeLoad(); // Best-effort: ensure the user has an active organisation set. diff --git a/apps/web/src/routes/home.tsx b/apps/web/src/routes/index.tsx similarity index 89% rename from apps/web/src/routes/home.tsx rename to apps/web/src/routes/index.tsx index db275ee..e4d77ab 100644 --- a/apps/web/src/routes/home.tsx +++ b/apps/web/src/routes/index.tsx @@ -2,7 +2,7 @@ import { createFileRoute } from "@tanstack/react-router"; import HomePage from "@/features/home/pages/home-page"; -export const Route = createFileRoute("/home")({ +export const Route = createFileRoute("/")({ component: HomePage, head: () => ({ meta: [ diff --git a/bun.lock b/bun.lock index 75b78f7..3716a50 100644 --- a/bun.lock +++ b/bun.lock @@ -13,11 +13,11 @@ "@open-learn/config": "workspace:*", "@types/node": "catalog:", "husky": "^9.1.7", - "lint-staged": "^16.1.2", + "lint-staged": "^16.4.0", "oxfmt": "^0.26.0", - "oxlint": "^1.41.0", - "turbo": "^2.8.12", - "typescript": "^5", + "oxlint": "^1.59.0", + "turbo": "^2.9.6", + "typescript": "^5.9.3", }, }, "apps/server": { @@ -73,11 +73,13 @@ "@trpc/tanstack-react-query": "^11.7.2", "ai": "catalog:", "better-auth": "catalog:", + "date-fns": "^4.1.0", "dotenv": "catalog:", "lucide-react": "catalog:", "motion": "^12.36.0", "next-themes": "catalog:", "react": "^19.2.3", + "react-big-calendar": "^1.19.4", "react-dom": "^19.2.3", "sonner": "catalog:", "streamdown": "^1.6.10", @@ -187,25 +189,26 @@ "name": "@open-learn/ui", "version": "0.0.0", "dependencies": { - "@base-ui/react": "^1.2.0", + "@base-ui/react": "^1.3.0", + "@fontsource-variable/inter": "^5.2.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", - "lucide-react": "catalog:", + "lucide-react": "^1.8.0", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "catalog:", "react-day-picker": "^9.14.0", "react-dom": "catalog:", - "react-resizable-panels": "^4.7.2", - "recharts": "2.15.4", - "shadcn": "^3.6.2", + "react-resizable-panels": "^4.10.0", + "recharts": "3.8.0", + "shadcn": "^4.2.0", "sonner": "^2.0.7", - "tailwind-merge": "^3.3.1", - "tw-animate-css": "^1.3.4", + "tailwind-merge": "^3.5.0", + "tw-animate-css": "^1.4.0", "vaul": "^1.1.2", }, "devDependencies": { @@ -254,8 +257,6 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - "@antfu/ni": ["@antfu/ni@25.0.0", "", { "dependencies": { "ansis": "^4.0.0", "fzf": "^0.5.2", "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" }, "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA=="], - "@authenio/xml-encryption": ["@authenio/xml-encryption@2.0.2", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "escape-html": "^1.0.3", "xpath": "0.0.32" } }, "sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg=="], "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], @@ -380,9 +381,9 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@base-ui/react": ["@base-ui/react@1.2.0", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@base-ui/utils": "0.2.5", "@floating-ui/react-dom": "^2.1.6", "@floating-ui/utils": "^0.2.10", "tabbable": "^6.4.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-O6aEQHcm+QyGTFY28xuwRD3SEJGZOBDpyjN2WvpfWYFVhg+3zfXPysAILqtM0C1kWC82MccOE/v1j+GHXE4qIw=="], + "@base-ui/react": ["@base-ui/react@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@base-ui/utils": "0.2.6", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "tabbable": "^6.4.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA=="], - "@base-ui/utils": ["@base-ui/utils@0.2.5", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw=="], + "@base-ui/utils": ["@base-ui/utils@0.2.6", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw=="], "@better-auth/core": ["@better-auth/core@1.5.1-beta.3", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-up7xBj99ki9UlTLEsZRfkheYHBLaYIt9JGI9lyAdQcbowTvXnWrk/fkgVcc2YNqRoPvBWCSooEdKL+b5OETQdQ=="], @@ -432,7 +433,7 @@ "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260301.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Q0wMJ4kcujXILwQKQFc1jaYamVsNvjuECzvRrTI8OxGFMx2yq9aOsswViE4X1gaS2YQQ5u0JGwuGi5WdT1Lt7A=="], - "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260310.1", "", {}, "sha512-Cg4gyGDtfimNMgBr2h06aGR5Bt8puUbblyzPNZN55mBfVYCTWwQiUd9PrbkcoddKrWHlsy0ACH/16dAeGf5BQg=="], + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260412.1", "", {}, "sha512-4jcYPBKH70/XOW40B6X/bEUlh+rjvem8wuvGJXuGebSScFcbJ5TuO5CjX/Nc8Y+RhH3RnTcynHX4tR6Rm0MNgA=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -530,6 +531,8 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="], + "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], "@hono/trpc-server": ["@hono/trpc-server@0.4.2", "", { "peerDependencies": { "@trpc/server": "^10.10.0 || >11.0.0-rc", "hono": ">=4.0.0" } }, "sha512-3TDrc42CZLgcTFkXQba+y7JlRWRiyw1AqhLqztWyNS2IFT+3bHld0lxKdGBttCtGKHYx0505dM67RMazjhdZqw=="], @@ -704,43 +707,43 @@ "@oxfmt/win32-x64": ["@oxfmt/win32-x64@0.26.0", "", { "os": "win32", "cpu": "x64" }, "sha512-m8TfIljU22i9UEIkD+slGPifTFeaCwIUfxszN3E6ABWP1KQbtwSw9Ak0TdoikibvukF/dtbeyG3WW63jv9DnEg=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.52.0", "", { "os": "android", "cpu": "arm" }, "sha512-fW2pmR1VzFEdcvOYeSiv+R7CqffOjr9Bv5QmZaHuHJ4ZCqouaF6o48N/hJ3H1n9Zd8PCMFgJkeqUvUsVce01mw=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-etYDw/UaEv936AQUd/CRMBVd+e+XuuU6wC+VzOv1STvsTyZenLChepLWqLtnyTTp4YMlM22ypzogDDwqYxv5cg=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.52.0", "", { "os": "android", "cpu": "arm64" }, "sha512-ptuJljIB+klNi8//qxXyGD51NLJXY9lv40Olc7l3/pEyjejWwXGvGMO0GM6f0JsjmbnDL+VkX7RVQNhByaX8WA=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TgLc7XVLKH2a4h8j3vn1MDjfK33i9MY60f/bKhRGWyVzbk5LCZ4X01VZG7iHrMmi5vYbAp8//Ponigx03CLsdw=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.52.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5d079Uw43BHVZzOwm3uJI2PgSbsZJTpfHDq2jMOR6rRjGiEBlgasaEvAA26VBqpkO1++/59ZCKLBnEpkro3zIg=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DXyFPf5ZKldMLloRHx/B9fsxsiTQomaw7cmEW3YIJko2HgCh+GUhp9gGYwHrqlLJPsEe3dYj9JebjX92D3j3AA=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.52.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-vRTjnhPEHAyfUhO9w6GM1VkxeVXFcDs+huyB5YNMw+Py+6PRYDFFrrOEr0rZYcoGtSH25ScozZV8I1UXrzaDjQ=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-LgvrsdgVLX1qWqIEmNsSmMXJhpAWdtUQ0M+oR0CySwi+9IHWyOGuIL8w8+u/kbZNMyZr4WUyYB5i0+D+AKgkLg=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.52.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vFthhhciRAliAjoKMsvi7UkkQp/EtMNhmCRYBuKsNiTH0k4H3SFfbuWWr80Q7+uTXijfBP91KO/EeF48RggC7A=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-bOJhqX/ny4hrFuTPlyk8foSRx/vLRpxJh0jOOKN2NWW6FScXHPAA5rQbrwdQPcgGB5V8Ua51RS03fke8ssBcug=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.52.0", "", { "os": "linux", "cpu": "arm" }, "sha512-qX3K4mKbju54ojUa8nigVxxZAUDBGu5MGzpoXvWmiw+7hafoQKaLAoTm94EqRlv9v27p864GQBgc4g3qYtMXXA=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-vVUXxYMF9trXCsz4m9H6U0IjehosVHxBzVgJUxly1uz4W1PdDyicaBnpC0KRXsHYretLVe+uS9pJy8iM57Kujw=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.52.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x5D5/EUS9U4kndPncLB6mDfCsv7i8XcRLu0DZyTngXvyqapc96WwmyyOG2j8Dt26aE8Ykgh6AhsHp9bQtoBUAw=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-TULQW8YBPGRWg5yZpFPL54HLOnJ3/HiX6VenDPi6YfxB/jlItwSMFh3/hCeSNbh+DAMaE1Py0j5MOaivHkI/9Q=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.52.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-2Ep1tnGLuGG7lUkKG/nilIJ0/T2rebEcATxMJ7afuhD6Z2Sc9dDcpX00IngAMyR9l6hXrvaOw9YA5HUAJVSENg=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Gt54Y4eqSgYJ90xipm24xeyaPV854706o/kiT8oZvUt3VDY7qqxdqyGqchMaujd87ib+/MXvnl9WkK8Cc1BExg=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.52.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-54wxvb1Pztz0GMgTLUG9HsH8uhZSL4UbG7n4PDxWIRT9TygTVYKfD6D7iasYdKg6ZpWB5Y86VMxgjSJpR/Y7bQ=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-3CtsKp7NFB3OfqQzbuAecrY7GIZeiv7AD+xutU4tefVQzlfmTI7/ygWLrvkzsDEjTlMq41rYHxgsn6Yh8tybmA=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.52.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-A82Zks1lJyLclrj8n2tJPHOw2ieZXCaBctnCarS1BRlPQMC1Y98vWCLqgvg9ssWy5ZAja0IjUHN1cYsp53mrqA=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-K0diOpT3ncDmOfl9I1HuvpEsAuTxkts0VYwIv/w6Xiy9CdwyPBVX88Ga9l8VlGgMrwBMnSY4xIvVlVY/fkQk7Q=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.52.0", "", { "os": "linux", "cpu": "none" }, "sha512-ci89Ou+u9vnA0r4eQqGm/KPEkpea+QEtZCLKkrOAD/K5ZBwjS8ToID6aMgsDbIOJUNBGufsmX0iCC7EWrNKQFA=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-xAU7+QDU6kTJJ7mJLOGgo7oOjtAtkKyFZ0Yjdb5cEo3DiCCPFLvyr08rWiQh6evZ7RiUTf+o65NY/bqttzJiQQ=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.52.0", "", { "os": "linux", "cpu": "none" }, "sha512-3/+DVDWajFSu69TaYnKkoUgMEcHR3puO8TcBu3fPCKRhbLjgwDiYIVRdvQX0QaSjkNPJARmpYq7vlPHWNo2cUA=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-KUmZmKlTTyauOnvUNVxK7G40sSSx0+w5l1UhaGsC6KPpOYHenx2oqJTnabmpLJicok7IC+3Y6fXAUOMyexaeJQ=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.52.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-BU7CbceOh00NDmY1IYr72qZoj4sJVHB9DCL2tIq2vyNllNJIpZWTxqlzdqmC4FViXWMy8kZNkOa+SdauH+EcoQ=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-4usRxC8gS0PGdkHnRmwJt/4zrQNZyk6vL0trCxwZSsAKM+OxhB8nKiR+mhjdBbl8lbMh2gc3bZpNN/ik8c4c2A=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.52.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JUVZ6TKYl1yArS3xGsNLQlZxgVpjNKtZFja6VxSTDy2ToN7H58PiDRcxWoN2XoIcWlHSvK7pkIPFNOyzdEJ23A=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-s/rNE2gDmbwAOOP493xk2X7M8LZfI1LJFSSW1+yanz3vuQCFPiHkx4GY+O1HuLUDtkzGlhtMrIcxxzyYLv308w=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.52.0", "", { "os": "linux", "cpu": "x64" }, "sha512-IatLKG6UUbIbTBjBZ9SIAYp4SIvOpYIXPXn9cMLqWxh9HrHsu0fLNL+VQ67y4vdlIleYLeuIHkAp3M6saIN1RQ=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+yYj1udJa2UvvIUmEm0IcKgc0UlPMgz0nsSTvkPL2y6n0uU5LgIHSwVu4AHhrve6j9BpVSoRksnz8c9QcvITJA=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.52.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CWgJ6FepHryuc/lgQWStFf3lcvEkbFLSa9zqO0D0QLVfrdg43I4XItKpL/bnfm4n7obzwgG8j8sBggdoxJQKfw=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-bUplUb48LYsB3hHlQXP2ZMOenpieWoOyppLAnnAhuPag3MGPnt+7caxE3w/Vl9wpQsTA3gzLntQi9rxWrs7Xqg=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.52.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-EuNAbPpctu8jYMZnvYh53Xw3YVY2nIi9bQlyMjY0eKiJxDv8ikHrAfcVcwTQW9xa5tp0eiMkmW7iHPP5CYUC9Q=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-/HLsLuz42rWl7h7ePdmMTpHm2HIDmPtcEMYgm5BBEHiEiuNOrzMaUpd2z7UnNni5LGN9obJy2YoAYBLXQwazrA=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.52.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-wu3fquQttzSXwyy8DfdOG3Kyb17yAbRhwPlly7NHSXkrffAEAmZ6+o38tCNgsReGLugbn/wbq4uS4nEQubCq+A=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-rUPy+JnanpPwV/aJCPnxAD1fW50+XPI0VkWr7f0vEbqcdsS8NpB24Rw6RsS7SdpFv8Dw+8ugCwao5nCFbqOUSg=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.52.0", "", { "os": "win32", "cpu": "x64" }, "sha512-wikx9I9J9/lPOZlrCCNgm8YjWkia8NZfhWd1TTvZTMguyChbw/oA2VEM6Fzx+kkpA+1qu5Mo7nrLdOXEJavw8g=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-xkE7puteDS/vUyRngLXW0t8WgdWoS/tfxXjhP/P7SMqPDx+hs44SpssO3h3qmTqECYEuXBUPzcAw5257Ka+ofA=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], @@ -752,6 +755,8 @@ "@polar-sh/ui": ["@polar-sh/ui@0.1.2", "", { "dependencies": { "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "countries-list": "^3.2.0", "date-fns": "^4.1.0", "input-otp": "^1.4.2", "lucide-react": "^0.547.0", "react-day-picker": "^9.11.1", "react-hook-form": "^7.65.0", "react-timeago": "^8.3.0", "recharts": "^3.3.0", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" } }, "sha512-YTmMB2lr+PplMTDZnTs0Crgu0KNBKyQcSX4N0FYXSlo1Q6e9IKs4hwzEcqNUv3eHS4BxGO1SvxxNjuSK+il49Q=="], + "@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="], + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], @@ -904,6 +909,8 @@ "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "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" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="], + "@restart/hooks": ["@restart/hooks@0.4.16", "", { "dependencies": { "dequal": "^2.0.3" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-beta.52", "", { "os": "android", "cpu": "arm64" }, "sha512-MBGIgysimZPqTDcLXI+i9VveijkP5C3EAncEogXhqfax6YXj1Tr2LY3DVuEOMIjWfMPMhtQSPup4fSTAmgjqIw=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.52", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MmKeoLnKu1d9j6r19K8B+prJnIZ7u+zQ+zGQ3YHXGnr41rzE3eqQLovlkvoZnRoxDGPA4ps0pGiwXy6YE3lJyg=="], @@ -1188,6 +1195,18 @@ "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], + "@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="], + + "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw=="], + + "@turbo/linux-64": ["@turbo/linux-64@2.9.6", "", { "os": "linux", "cpu": "x64" }, "sha512-YKi05jnNHaD7vevgYwahpzGwbsNNTwzU2c7VZdmdFm7+cGDP4oREUWSsainiMfRqjRuolQxBwRn8wf1jmu+YZA=="], + + "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-02o/ZS69cOYEDczXvOB2xmyrtzjQ2hVFtWZK1iqxXUfzMmTjZK4UumrfNnjckSg+gqeBfnPRHa0NstA173Ik3g=="], + + "@turbo/windows-64": ["@turbo/windows-64@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-wVdQjvnBI15wB6JrA+43CtUtagjIMmX6XYO758oZHAsCNSxqRlJtdyujih0D8OCnwCRWiGWGI63zAxR0hO6s9g=="], + + "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-1XUUyWW0W6FTSqGEhU8RHVqb2wP1SPkr7hIvBlMEwH9jr+sJQK5kqeosLJ/QaUv4ecSAd1ZhIrLoW7qslAzT4A=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -1276,7 +1295,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], + "@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], "@types/nodemailer": ["@types/nodemailer@6.4.23", "", { "dependencies": { "@types/node": "*" } }, "sha512-aFV3/NsYFLSx9mbb5gtirBSXJnAlrusoKNuPbxsASWc7vrKLmIrTQRpdcxNcSFL3VW2A2XpeLEavwb2qMi6nlQ=="], @@ -1296,6 +1315,8 @@ "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], + "@types/warning": ["@types/warning@3.0.3", "", {}, "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q=="], + "@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="], "@types/whatwg-url": ["@types/whatwg-url@13.0.0", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q=="], @@ -1556,6 +1577,8 @@ "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "date-arithmetic": ["date-arithmetic@4.1.0", "", {}, "sha512-QWxYLR5P/6GStZcdem+V1xoto6DMadYWpMXU82ES3/RfR3Wdwr3D0+be7mgOJ+Ov0G9D5Dmb9T17sNLQYj9XOg=="], + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], @@ -1604,7 +1627,7 @@ "dompurify": ["dompurify@3.3.2", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ=="], - "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], + "dotenv": ["dotenv@17.4.1", "", {}, "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw=="], "drizzle-kit": ["drizzle-kit@0.31.9", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg=="], @@ -1674,7 +1697,7 @@ "event-source-plus": ["event-source-plus@0.1.15", "", { "dependencies": { "ofetch": "^1.5.1" } }, "sha512-kt3z/UwDbZxHttynwmXlqTf1qknWqPgswsbvSok1ob6SveMts4BqRXow6aiwB55xTY1XvSXuhn+IvYQErWLyKA=="], - "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], @@ -1696,8 +1719,6 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="], - "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "fast-json-patch": ["fast-json-patch@3.1.1", "", {}, "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ=="], @@ -1742,8 +1763,6 @@ "fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="], - "fzf": ["fzf@0.5.2", "", {}, "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q=="], - "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], @@ -1772,6 +1791,8 @@ "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "globalize": ["globalize@0.1.1", "", {}, "sha512-5e01v8eLGfuQSOvx2MsDMOWS0GFtCx1wPzQSmcHw4hkxFzrQDBO3Xwg/m8Hr/7qXMrHeOIE29qWVzyv06u1TZA=="], + "goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="], "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], @@ -1860,6 +1881,8 @@ "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="], + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -1990,7 +2013,7 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], - "lint-staged": ["lint-staged@16.3.3", "", { "dependencies": { "commander": "^14.0.3", "listr2": "^9.0.5", "micromatch": "^4.0.8", "string-argv": "^0.3.2", "tinyexec": "^1.0.2", "yaml": "^2.8.2" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-RLq2koZ5fGWrx7tcqx2tSTMQj4lRkfNJaebO/li/uunhCJbtZqwTuwPHpgIimAHHi/2nZIiGrkCHDCOeR1onxA=="], + "lint-staged": ["lint-staged@16.4.0", "", { "dependencies": { "commander": "^14.0.3", "listr2": "^9.0.5", "picomatch": "^4.0.3", "string-argv": "^0.3.2", "tinyexec": "^1.0.4", "yaml": "^2.8.2" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw=="], "listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="], @@ -2014,7 +2037,9 @@ "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], - "lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="], + "lucide-react": ["lucide-react@1.8.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="], + + "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -2060,6 +2085,8 @@ "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="], + "memory-pager": ["memory-pager@1.5.0", "", {}, "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="], "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], @@ -2154,6 +2181,10 @@ "mlly": ["mlly@1.8.1", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ=="], + "moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="], + + "moment-timezone": ["moment-timezone@0.5.48", "", { "dependencies": { "moment": "^2.29.4" } }, "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw=="], + "mongodb": ["mongodb@7.1.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.3.0", "bson": "^7.1.1", "mongodb-connection-string-url": "^7.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.806.0", "@mongodb-js/zstd": "^7.0.0", "gcp-metadata": "^7.0.1", "kerberos": "^7.0.0", "mongodb-client-encryption": ">=7.0.0 <7.1.0", "snappy": "^7.3.2", "socks": "^2.8.6" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg=="], "mongodb-connection-string-url": ["mongodb-connection-string-url@7.0.1", "", { "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" } }, "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ=="], @@ -2234,7 +2265,7 @@ "oxfmt": ["oxfmt@0.26.0", "", { "dependencies": { "tinypool": "2.0.0" }, "optionalDependencies": { "@oxfmt/darwin-arm64": "0.26.0", "@oxfmt/darwin-x64": "0.26.0", "@oxfmt/linux-arm64-gnu": "0.26.0", "@oxfmt/linux-arm64-musl": "0.26.0", "@oxfmt/linux-x64-gnu": "0.26.0", "@oxfmt/linux-x64-musl": "0.26.0", "@oxfmt/win32-arm64": "0.26.0", "@oxfmt/win32-x64": "0.26.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-UDD1wFNwfeorMm2ZY0xy1KRAAvJ5NjKBfbDmiMwGP7baEHTq65cYpC0aPP+BGHc8weXUbSZaK8MdGyvuRUvS4Q=="], - "oxlint": ["oxlint@1.52.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.52.0", "@oxlint/binding-android-arm64": "1.52.0", "@oxlint/binding-darwin-arm64": "1.52.0", "@oxlint/binding-darwin-x64": "1.52.0", "@oxlint/binding-freebsd-x64": "1.52.0", "@oxlint/binding-linux-arm-gnueabihf": "1.52.0", "@oxlint/binding-linux-arm-musleabihf": "1.52.0", "@oxlint/binding-linux-arm64-gnu": "1.52.0", "@oxlint/binding-linux-arm64-musl": "1.52.0", "@oxlint/binding-linux-ppc64-gnu": "1.52.0", "@oxlint/binding-linux-riscv64-gnu": "1.52.0", "@oxlint/binding-linux-riscv64-musl": "1.52.0", "@oxlint/binding-linux-s390x-gnu": "1.52.0", "@oxlint/binding-linux-x64-gnu": "1.52.0", "@oxlint/binding-linux-x64-musl": "1.52.0", "@oxlint/binding-openharmony-arm64": "1.52.0", "@oxlint/binding-win32-arm64-msvc": "1.52.0", "@oxlint/binding-win32-ia32-msvc": "1.52.0", "@oxlint/binding-win32-x64-msvc": "1.52.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-InLldD+6+3iHJGIrtU1W37UIpsg+xoGCemkZCuSQhxUO3evMX+L872ONvbECyRza9k7ScMCukJIK3Al/2ZMDnQ=="], + "oxlint": ["oxlint@1.59.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.59.0", "@oxlint/binding-android-arm64": "1.59.0", "@oxlint/binding-darwin-arm64": "1.59.0", "@oxlint/binding-darwin-x64": "1.59.0", "@oxlint/binding-freebsd-x64": "1.59.0", "@oxlint/binding-linux-arm-gnueabihf": "1.59.0", "@oxlint/binding-linux-arm-musleabihf": "1.59.0", "@oxlint/binding-linux-arm64-gnu": "1.59.0", "@oxlint/binding-linux-arm64-musl": "1.59.0", "@oxlint/binding-linux-ppc64-gnu": "1.59.0", "@oxlint/binding-linux-riscv64-gnu": "1.59.0", "@oxlint/binding-linux-riscv64-musl": "1.59.0", "@oxlint/binding-linux-s390x-gnu": "1.59.0", "@oxlint/binding-linux-x64-gnu": "1.59.0", "@oxlint/binding-linux-x64-musl": "1.59.0", "@oxlint/binding-openharmony-arm64": "1.59.0", "@oxlint/binding-win32-arm64-msvc": "1.59.0", "@oxlint/binding-win32-ia32-msvc": "1.59.0", "@oxlint/binding-win32-x64-msvc": "1.59.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-0xBLeGGjP4vD9pygRo8iuOkOzEU1MqOnfiOl7KYezL/QvWL8NUg6n03zXc7ZVqltiOpUxBk2zgHI3PnRIEdAvw=="], "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], @@ -2340,6 +2371,8 @@ "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "react-big-calendar": ["react-big-calendar@1.19.4", "", { "dependencies": { "@babel/runtime": "^7.20.7", "clsx": "^1.2.1", "date-arithmetic": "^4.1.0", "dayjs": "^1.11.7", "dom-helpers": "^5.2.1", "globalize": "^0.1.1", "invariant": "^2.2.4", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "luxon": "^3.2.1", "memoize-one": "^6.0.0", "moment": "^2.29.4", "moment-timezone": "^0.5.40", "prop-types": "^15.8.1", "react-overlays": "^5.2.1", "uncontrollable": "^7.2.1" }, "peerDependencies": { "react": "^16.14.0 || ^17 || ^18 || ^19", "react-dom": "^16.14.0 || ^17 || ^18 || ^19" } }, "sha512-FrvbDx2LF6JAWFD96LU1jjloppC5OgIvMYUYIPzAw5Aq+ArYFPxAjLqXc4DyxfsQDN0TJTMuS/BIbcSB7Pg0YA=="], + "react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], @@ -2348,6 +2381,10 @@ "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "react-lifecycles-compat": ["react-lifecycles-compat@3.0.4", "", {}, "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="], + + "react-overlays": ["react-overlays@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.13.8", "@popperjs/core": "^2.11.6", "@restart/hooks": "^0.4.7", "@types/warning": "^3.0.0", "dom-helpers": "^5.2.0", "prop-types": "^15.7.2", "uncontrollable": "^7.2.1", "warning": "^4.0.3" }, "peerDependencies": { "react": ">=16.3.0", "react-dom": ">=16.3.0" } }, "sha512-GLLSOLWr21CqtJn8geSwQfoJufdt3mfdsnIiQswouuQ2MMPns+ihZklxvsTDKD3cR2tF8ELbi5xUsvqVhR6WvA=="], + "react-redux": ["react-redux@9.2.0", "", { "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" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], @@ -2356,25 +2393,19 @@ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], - "react-resizable-panels": ["react-resizable-panels@4.7.2", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-1L2vyeBG96hp7N6x6rzYXJ8EjYiDiffMsqj3cd+T9aOKwscvuyCn2CuZ5q3PoUSTIJUM6Q5DgXH1bdDe6uvh2w=="], - - "react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="], + "react-resizable-panels": ["react-resizable-panels@4.10.0", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-frjewRQt7TCv/vCH1pJfjZ7RxAhr5pKuqVQtVgzFq/vherxBFOWyC3xMbryx5Ti2wylViGUFc93Etg4rB3E0UA=="], "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], "react-timeago": ["react-timeago@8.3.0", "", { "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-BeR0hj/5qqTc2+zxzBSQZMky6MmqwOtKseU3CSmcjKR5uXerej2QY34v2d+cdz11PoeVfAdWLX+qjM/UdZkUUg=="], - "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], - "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], - "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="], - - "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="], + "recharts": ["recharts@3.8.0", "", { "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" }, "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" } }, "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ=="], "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], @@ -2482,7 +2513,7 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "shadcn": ["shadcn@3.8.5", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-jPRx44e+eyeV7xwY3BLJXcfrks00+M0h5BGB9l6DdcBW4BpAj4x3lVmVy0TXPEs2iHEisxejr62sZAAw6B1EVA=="], + "shadcn": ["shadcn@4.2.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-ZDuV340itidaUd4Gi1BxQX+Y7Ush6BHp6URZBM2RyxUUBZ6yFtOWIr4nVY+Ro+YRSpo82v7JrsmtcU5xoBCMJQ=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], @@ -2580,7 +2611,7 @@ "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], - "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + "tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], @@ -2616,19 +2647,7 @@ "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], - "turbo": ["turbo@2.8.15", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.15", "turbo-darwin-arm64": "2.8.15", "turbo-linux-64": "2.8.15", "turbo-linux-arm64": "2.8.15", "turbo-windows-64": "2.8.15", "turbo-windows-arm64": "2.8.15" }, "bin": { "turbo": "bin/turbo" } }, "sha512-ERZf7pKOR155NKs/PZt1+83NrSEJfUL7+p9/TGZg/8xzDVMntXEFQlX4CsNJQTyu4h3j+dZYiQWOOlv5pssuHQ=="], - - "turbo-darwin-64": ["turbo-darwin-64@2.8.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-EElCh+Ltxex9lXYrouV3hHjKP3HFP31G91KMghpNHR/V99CkFudRcHcnWaorPbzAZizH1m8o2JkLL8rptgb8WQ=="], - - "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ORmvtqHiHwvNynSWvLIleyU8dKtwQ4ILk39VsEwfKSEzSHWYWYxZhBmD9GAGRPlNl7l7S1irrziBlDEGVpq+vQ=="], - - "turbo-linux-64": ["turbo-linux-64@2.8.15", "", { "os": "linux", "cpu": "x64" }, "sha512-Bk1E61a+PCWUTfhqfXFlhEJMLp6nak0J0Qt14IZX1og1zyaiBLkM6M1GQFbPpiWfbUcdLwRaYQhO0ySB07AJ8w=="], - - "turbo-linux-arm64": ["turbo-linux-arm64@2.8.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-3BX0Vk+XkP0uiZc8pkjQGNsAWjk5ojC53bQEMp6iuhSdWpEScEFmcT6p7DL7bcJmhP2mZ1HlAu0A48wrTGCtvg=="], - - "turbo-windows-64": ["turbo-windows-64@2.8.15", "", { "os": "win32", "cpu": "x64" }, "sha512-m14ogunMF+grHZ1jzxSCO6q0gEfF1tmr+0LU+j1QNd/M1X33tfKnQqmpkeUR/REsGjfUlkQlh6PAzqlT3cA3Pg=="], - - "turbo-windows-arm64": ["turbo-windows-arm64@2.8.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-HWh6dnzhl7nu5gRwXeqP61xbyDBNmQ4UCeWNa+si4/6RAtHlKEcZTNs7jf4U+oqBnbtv4uxbKZZPf/kN0EK4+A=="], + "turbo": ["turbo@2.9.6", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.6", "@turbo/darwin-arm64": "2.9.6", "@turbo/linux-64": "2.9.6", "@turbo/linux-arm64": "2.9.6", "@turbo/windows-64": "2.9.6", "@turbo/windows-arm64": "2.9.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg=="], "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], @@ -2642,6 +2661,8 @@ "unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="], + "uncontrollable": ["uncontrollable@7.2.1", "", { "dependencies": { "@babel/runtime": "^7.6.3", "@types/react": ">=16.9.11", "invariant": "^2.2.4", "react-lifecycles-compat": "^3.0.4" }, "peerDependencies": { "react": ">=15.0.0" } }, "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ=="], + "undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -2704,7 +2725,7 @@ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "victory-vendor": ["victory-vendor@36.9.2", "", { "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" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], + "victory-vendor": ["victory-vendor@37.3.6", "", { "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" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], @@ -2720,6 +2741,8 @@ "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + "warning": ["warning@4.0.3", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w=="], + "web": ["web@workspace:apps/web"], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], @@ -2790,6 +2813,8 @@ "@ai-sdk/provider-utils/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + "@antfu/install-pkg/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + "@authenio/xml-encryption/xpath": ["xpath@0.0.32", "", {}, "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw=="], "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], @@ -2832,6 +2857,8 @@ "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + "@dotenvx/dotenvx/dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], + "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "@ecies/ciphers/@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], @@ -2848,14 +2875,14 @@ "@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@neondatabase/serverless/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], + "@noble/curves/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], "@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], "@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], - "@polar-sh/checkout/eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], - "@polar-sh/ui/@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], "@polar-sh/ui/@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="], @@ -2864,8 +2891,6 @@ "@polar-sh/ui/lucide-react": ["lucide-react@0.547.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-YLChGBWKq8ynr1UWP8WWRPhHhyuBAXfSBnHSgfoj51L//9TU3d0zvxpigf5C1IJ4vnEoTzthl5awPK55PiZhdA=="], - "@polar-sh/ui/recharts": ["recharts@3.8.0", "", { "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" }, "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" } }, "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ=="], - "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], "@prisma/dev/@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], @@ -2982,8 +3007,14 @@ "@ts-morph/common/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + "@types/nodemailer/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], + + "@types/pg/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], + "ai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + "alchemy/@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260310.1", "", {}, "sha512-Cg4gyGDtfimNMgBr2h06aGR5Bt8puUbblyzPNZN55mBfVYCTWwQiUd9PrbkcoddKrWHlsy0ACH/16dAeGf5BQg=="], + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "better-auth/@better-auth/core": ["@better-auth/core@1.5.2", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-svaKRVN/p3+g++kljLEedHC+RgDlGsVr87tKiATr5xIE7xqLO1If906pMTNMfhF08N5r7pMbix/mRYdObuPKHA=="], @@ -3036,8 +3067,6 @@ "langium/chevrotain": ["chevrotain@11.1.2", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.1.2", "@chevrotain/gast": "11.1.2", "@chevrotain/regexp-to-ast": "11.1.2", "@chevrotain/types": "11.1.2", "@chevrotain/utils": "11.1.2", "lodash-es": "4.17.23" } }, "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg=="], - "listr2/eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], - "log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], @@ -3054,6 +3083,8 @@ "nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="], + "nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + "ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], @@ -3066,6 +3097,8 @@ "radix-ui/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "react-big-calendar/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="], + "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.52", "", {}, "sha512-/L0htLJZbaZFL1g9OHOblTxbCYIGefErJjtYOwgl9ZqNx27P3L0SDfjhhHIss32gu5NWgnxuT2a2Hnnv6QGHKA=="], @@ -3086,10 +3119,14 @@ "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "tsdown/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + "tsx/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], "unrun/rolldown": ["rolldown@1.0.0-rc.8", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.8" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.8", "@rolldown/binding-darwin-arm64": "1.0.0-rc.8", "@rolldown/binding-darwin-x64": "1.0.0-rc.8", "@rolldown/binding-freebsd-x64": "1.0.0-rc.8", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.8", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.8", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.8", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.8", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.8", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.8", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.8", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.8", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.8", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.8", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.8" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-RGOL7mz/aoQpy/y+/XS9iePBfeNRDUdozrhCEJxdpJyimW8v6yp4c30q6OviUU5AnUJVLRL9GP//HUs6N3ALrQ=="], + "web/lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="], + "wrangler/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.15.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-EGYmJaGZKWl+X8tXxcnx4v2bOZSjQeNI5dWFeXivgX9+YCT69AkzHHwlNbVpqtEUTbew8eQurpyOpeN8fg00nw=="], "wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], @@ -3200,10 +3237,6 @@ "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], - "@polar-sh/ui/recharts/eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], - - "@polar-sh/ui/recharts/victory-vendor": ["victory-vendor@37.3.6", "", { "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" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], - "@tanstack/router-plugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], diff --git a/package.json b/package.json index c031372..5f778f2 100644 --- a/package.json +++ b/package.json @@ -61,11 +61,11 @@ "@open-learn/config": "workspace:*", "@types/node": "catalog:", "husky": "^9.1.7", - "lint-staged": "^16.1.2", + "lint-staged": "^16.4.0", "oxfmt": "^0.26.0", - "oxlint": "^1.41.0", - "turbo": "^2.8.12", - "typescript": "^5" + "oxlint": "^1.59.0", + "turbo": "^2.9.6", + "typescript": "^5.9.3" }, "lint-staged": { "*": [ diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 43f9f51..c0fce85 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -15,9 +15,28 @@ const database: BetterAuthOptions["database"] = drizzleAdapter(db, { schema: schema, }); +// Derive shared parent domain for cross-subdomain cookies when deployed on workers.dev. +// e.g. "https://open-clock-web-dev.ludvig1411.workers.dev" → ".ludvig1411.workers.dev" +// Falls back to undefined for local development so cookies stay scoped to localhost. +const workersDomain = env.CORS_ORIGIN.includes("workers.dev") + ? `.${new URL(env.CORS_ORIGIN).hostname.split(".").slice(-3).join(".")}` + : undefined; + +// Build the list of trusted origins. Always include the primary CORS_ORIGIN. +// During local dev the web worker binds VITE_SERVER_URL to localhost:3002 and +// the browser reaches it from localhost:3001/3003, so we also trust localhost +// variants to avoid 403 preflight failures against the deployed dev server. +const trustedOrigins = [ + env.CORS_ORIGIN, + // localhost variants for local development + "http://localhost:3001", + "http://localhost:3002", + "http://localhost:3003", +]; + export const auth = betterAuth({ database, - trustedOrigins: [env.CORS_ORIGIN], + trustedOrigins, emailAndPassword: { enabled: true, }, @@ -31,13 +50,12 @@ export const auth = betterAuth({ clientSecret: env.GITHUB_CLIENT_SECRET, }, }, - // uncomment cookieCache setting when ready to deploy to Cloudflare using *.workers.dev domains - // session: { - // cookieCache: { - // enabled: true, - // maxAge: 60, - // }, - // }, + session: { + cookieCache: { + enabled: !!workersDomain, + maxAge: 60, + }, + }, secret: env.BETTER_AUTH_SECRET, baseURL: env.BETTER_AUTH_URL, advanced: { @@ -46,12 +64,12 @@ export const auth = betterAuth({ secure: true, httpOnly: true, }, - // uncomment crossSubDomainCookies setting when ready to deploy and replace with your actual workers subdomain - // https://developers.cloudflare.com/workers/wrangler/configuration/#workersdev - // crossSubDomainCookies: { - // enabled: true, - // domain: "", - // }, + ...(workersDomain && { + crossSubDomainCookies: { + enabled: true, + domain: workersDomain, + }, + }), }, plugins: [ organization({ diff --git a/packages/infra/.wrangler/cache/cf.json b/packages/infra/.wrangler/cache/cf.json index 6a0a59d..2deb9ed 100644 --- a/packages/infra/.wrangler/cache/cf.json +++ b/packages/infra/.wrangler/cache/cf.json @@ -4,33 +4,34 @@ "requestPriority": "", "edgeRequestKeepAliveStatus": 1, "requestHeaderNames": {}, - "clientTcpRtt": 12, + "clientTcpRtt": 14, + "clientQuicRtt": 0, "colo": "ARN", "asn": 1257, "asOrganization": "Tele2 customer broadband access", "country": "SE", "isEUCountry": "1", - "city": "Göteborg", + "city": "Kista", "continent": "EU", - "region": "Västra Götaland", - "regionCode": "O", + "region": "Stockholm", + "regionCode": "AB", "timezone": "Europe/Stockholm", - "longitude": "11.96679", - "latitude": "57.70716", - "postalCode": "400 10", + "longitude": "17.94479", + "latitude": "59.40316", + "postalCode": "164 00", "tlsVersion": "TLSv1.3", "tlsCipher": "AEAD-AES128-GCM-SHA256", - "tlsClientRandom": "Nyv6CkgcunR9uqYCYkY17dt8voUbzMJLHihPdj8F8j4=", + "tlsClientRandom": "JO4ISCxEhiRN+Ep6ilAn1MYj1cb3GlOo/t8i1EPNgfM=", "tlsClientCiphersSha1": "z756bn+x21PiebYy319nCdFpEYo=", - "tlsClientExtensionsSha1": "M0G+OspD/xblYCuewmNntzf3ayw=", - "tlsClientExtensionsSha1Le": "P1zYSzNBfI+jMRDwEW74yx0EBM0=", + "tlsClientExtensionsSha1": "g7CUyX4IGfVWT4opL1PEzZ2U1EQ=", + "tlsClientExtensionsSha1Le": "29pLmat8Ljr3wiUemHNTUe0plFA=", "tlsExportedAuthenticator": { - "clientHandshake": "4aa7b32bd4aac1b06f9581c21c45b8847d95ebf33e0d400ba4160af2d6036d24", - "serverHandshake": "dfba0c19424e11647a453764c7cc9cf90257df81e923a5d4542f84c9711fecf0", - "clientFinished": "7f0ce1b4e1c4ac461adfc94e036db987f92269e7e0fd3e05b988962b0d235dac", - "serverFinished": "8a38cb532ff0a66d4a48a55d08e2540ba95fe243da80478087a25415b0b86305" + "clientHandshake": "b49df0b5da2bcab2a710945259c0339e974b16fd6858167684ea8ee59b3a4ebe", + "serverHandshake": "fb3ab39fa83548b11202604496fb7bb2fb9bdcb63344fbc1c2cb4f3a98850b2b", + "clientFinished": "e5407c5cf5a59bcc01d7ee65037053a6c71e02100c0dff8474012be6f0661b45", + "serverFinished": "4acc301e69615198e0f0897a2c7ce6056c2b1221d1ab5f7b263daf3e454d4b9d" }, - "tlsClientHelloLength": "508", + "tlsClientHelloLength": "532", "tlsClientAuth": { "certPresented": "0", "certVerified": "NONE", @@ -48,9 +49,14 @@ "certFingerprintSHA1": "", "certFingerprintSHA256": "", "certNotBefore": "", - "certNotAfter": "" + "certNotAfter": "", + "certRFC9440": "", + "certRFC9440TooLarge": false, + "certChainRFC9440": "", + "certChainRFC9440TooLarge": false }, "verifiedBotCategory": "", + "edgeL4": { "deliveryRate": 259111 }, "botManagement": { "corporateProxy": false, "verifiedBot": false, diff --git a/packages/infra/alchemy.run.ts b/packages/infra/alchemy.run.ts index 358ecbf..5c3a1d0 100644 --- a/packages/infra/alchemy.run.ts +++ b/packages/infra/alchemy.run.ts @@ -1,5 +1,6 @@ import alchemy from "alchemy"; -import { CloudflareStateStore, Vite, Worker } from "alchemy/cloudflare"; +import { CloudflareStateStore } from "alchemy/state"; +import { createCloudflareApi, Vite, Worker } from "alchemy/cloudflare"; import { config } from "dotenv"; config({ path: "./.env" }); @@ -7,10 +8,12 @@ config({ path: "../../apps/web/.env" }); config({ path: "../../apps/server/.env" }); const app = await alchemy("open-clock", { - stateStore: process.env.CI ? (scope) => new CloudflareStateStore(scope) : undefined, + stateStore: process.env.ALCHEMY_STATE_TOKEN + ? (scope) => new CloudflareStateStore(scope) + : undefined, }); -// Load stage-specific overrides after alchemy resolves the real stage. +// NOTE: Load stage-specific overrides after alchemy resolves the real stage. // `alchemy dev` (no --stage) resolves to $USER — no .env.$USER file exists, // so localhost values from apps/server/.env are preserved. // `alchemy deploy --stage dev/prod` resolves to "dev"/"prod" and loads the @@ -53,4 +56,63 @@ export const web = await Vite("web", { console.log(`Web -> ${web.url}`); console.log(`Server -> ${server.url}`); +// Ensure the Cloudflare Access application protecting the server worker has a +// bypass policy for /api/auth/* so that Better Auth's sign-in endpoints are +// publicly reachable. Without this, OPTIONS preflight requests (which carry no +// Access JWT) are rejected by the Access layer before the Worker runs, producing +// a 403 with no CORS headers. +// +// This only runs when a CLOUDFLARE_API_TOKEN is present (i.e. during `deploy`). +// Local `alchemy dev` skips this because the dev server runs directly on localhost +// without a Cloudflare Access gate. +if (process.env.CLOUDFLARE_API_TOKEN) { + const cf = await createCloudflareApi(); + + // Find the Access application for this server worker hostname. + const serverHostname = new URL(server.url).hostname; + const appsRes = await cf.get(`/accounts/${cf.accountId}/access/apps`); + const appsJson = (await appsRes.json()) as { + result: Array<{ id: string; domain: string; name: string }>; + }; + const accessApp = appsJson.result?.find( + (a) => a.domain === serverHostname || a.domain === `${serverHostname}/*`, + ); + + if (accessApp) { + // Upsert a bypass policy for /api/auth/* so auth routes skip Access checks. + const policiesRes = await cf.get( + `/accounts/${cf.accountId}/access/apps/${accessApp.id}/policies`, + ); + const policiesJson = (await policiesRes.json()) as { + result: Array<{ id: string; name: string; decision: string }>; + }; + const bypassPolicy = policiesJson.result?.find((p) => p.name === "Bypass auth API"); + + const bypassBody = { + name: "Bypass auth API", + decision: "bypass", + include: [{ everyone: {} }], + // Apply only to the auth sub-paths + precedence: 1, + }; + + if (bypassPolicy) { + await cf.put( + `/accounts/${cf.accountId}/access/apps/${accessApp.id}/policies/${bypassPolicy.id}`, + bypassBody, + ); + console.log(`Access: updated bypass policy on ${serverHostname}/api/auth/*`); + } else { + await cf.post(`/accounts/${cf.accountId}/access/apps/${accessApp.id}/policies`, bypassBody); + console.log(`Access: created bypass policy on ${serverHostname}/api/auth/*`); + } + } else { + console.warn( + `Access: no Access application found for ${serverHostname} — skipping bypass policy.\n` + + `If the server worker is protected by Cloudflare Access, add a bypass policy\n` + + `for /api/auth/* manually in the Zero Trust dashboard.`, + ); + } +} + await app.finalize(); diff --git a/packages/ui/components.json b/packages/ui/components.json index 84d6fc7..ae52a46 100644 --- a/packages/ui/components.json +++ b/packages/ui/components.json @@ -20,5 +20,6 @@ }, "menuColor": "default", "menuAccent": "subtle", - "registries": {} + "registries": {}, + "rtl": false } diff --git a/packages/ui/package.json b/packages/ui/package.json index 2a2a9dc..01aa087 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -14,25 +14,26 @@ "check-types": "tsc --noEmit" }, "dependencies": { - "@base-ui/react": "^1.2.0", + "@base-ui/react": "^1.3.0", + "@fontsource-variable/inter": "^5.2.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", - "lucide-react": "catalog:", + "lucide-react": "^1.8.0", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "catalog:", "react-day-picker": "^9.14.0", "react-dom": "catalog:", - "react-resizable-panels": "^4.7.2", - "recharts": "2.15.4", - "shadcn": "^3.6.2", + "react-resizable-panels": "^4.10.0", + "recharts": "3.8.0", + "shadcn": "^4.2.0", "sonner": "^2.0.7", - "tailwind-merge": "^3.3.1", - "tw-animate-css": "^1.3.4", + "tailwind-merge": "^3.5.0", + "tw-animate-css": "^1.4.0", "vaul": "^1.1.2" }, "devDependencies": { diff --git a/packages/ui/src/components/accordion.tsx b/packages/ui/src/components/accordion.tsx index 880a702..b5e56a1 100644 --- a/packages/ui/src/components/accordion.tsx +++ b/packages/ui/src/components/accordion.tsx @@ -1,3 +1,5 @@ +"use client"; + import * as React from "react"; import { Accordion as AccordionPrimitive } from "radix-ui"; diff --git a/packages/ui/src/components/alert-dialog.tsx b/packages/ui/src/components/alert-dialog.tsx index cc24f55..f44fe40 100644 --- a/packages/ui/src/components/alert-dialog.tsx +++ b/packages/ui/src/components/alert-dialog.tsx @@ -1,3 +1,5 @@ +"use client"; + import * as React from "react"; import { AlertDialog as AlertDialogPrimitive } from "radix-ui"; @@ -48,7 +50,7 @@ function AlertDialogContent({ data-slot="alert-dialog-content" data-size={size} className={cn( - "group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-none bg-background p-4 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", + "group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-none bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className, )} {...props} @@ -104,7 +106,7 @@ function AlertDialogTitle({ ) {
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", + "font-heading font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", className, )} {...props} diff --git a/packages/ui/src/components/aspect-ratio.tsx b/packages/ui/src/components/aspect-ratio.tsx index b5993d1..b66a572 100644 --- a/packages/ui/src/components/aspect-ratio.tsx +++ b/packages/ui/src/components/aspect-ratio.tsx @@ -1,5 +1,3 @@ -"use client"; - import { AspectRatio as AspectRatioPrimitive } from "radix-ui"; function AspectRatio({ ...props }: React.ComponentProps) { diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index a64bb5b..aab5f71 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -5,7 +5,7 @@ import { Slot } from "radix-ui"; import { cn } from "@open-learn/ui/lib/utils"; const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-none border border-transparent bg-clip-padding text-xs font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/button inline-flex shrink-0 items-center justify-center rounded-none border border-transparent bg-clip-padding text-xs font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { @@ -25,7 +25,7 @@ const buttonVariants = cva( "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", xs: "h-6 gap-1 rounded-none px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-7 gap-1 rounded-none px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", icon: "size-8", "icon-xs": "size-6 rounded-none [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-7 rounded-none", diff --git a/packages/ui/src/components/calendar.tsx b/packages/ui/src/components/calendar.tsx index 5a547d9..5b52ab8 100644 --- a/packages/ui/src/components/calendar.tsx +++ b/packages/ui/src/components/calendar.tsx @@ -1,5 +1,3 @@ -"use client"; - import * as React from "react"; import { DayPicker, getDefaultClassNames, type DayButton, type Locale } from "react-day-picker"; diff --git a/packages/ui/src/components/card.tsx b/packages/ui/src/components/card.tsx index 379ed04..8ab8f96 100644 --- a/packages/ui/src/components/card.tsx +++ b/packages/ui/src/components/card.tsx @@ -37,7 +37,10 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) { return (
); diff --git a/packages/ui/src/components/carousel.tsx b/packages/ui/src/components/carousel.tsx index 9edb6b8..4e76a1a 100644 --- a/packages/ui/src/components/carousel.tsx +++ b/packages/ui/src/components/carousel.tsx @@ -1,3 +1,5 @@ +"use client"; + import * as React from "react"; import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react"; diff --git a/packages/ui/src/components/chart.tsx b/packages/ui/src/components/chart.tsx index c766b84..159ac58 100644 --- a/packages/ui/src/components/chart.tsx +++ b/packages/ui/src/components/chart.tsx @@ -2,21 +2,26 @@ import * as React from "react"; import * as RechartsPrimitive from "recharts"; +import type { TooltipValueType } from "recharts"; import { cn } from "@open-learn/ui/lib/utils"; // Format: { THEME_NAME: CSS_SELECTOR } const THEMES = { light: "", dark: ".dark" } as const; -export type ChartConfig = { - [k in string]: { +const INITIAL_DIMENSION = { width: 320, height: 200 } as const; +type TooltipNameType = number | string; + +export type ChartConfig = Record< + string, + { label?: React.ReactNode; icon?: React.ComponentType; } & ( | { color?: string; theme?: never } | { color?: never; theme: Record } - ); -}; + ) +>; type ChartContextProps = { config: ChartConfig; @@ -39,13 +44,18 @@ function ChartContainer({ className, children, config, + initialDimension = INITIAL_DIMENSION, ...props }: React.ComponentProps<"div"> & { config: ChartConfig; children: React.ComponentProps["children"]; + initialDimension?: { + width: number; + height: number; + }; }) { const uniqueId = React.useId(); - const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; + const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`; return ( @@ -59,14 +69,16 @@ function ChartContainer({ {...props} > - {children} + + {children} +
); } const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { - const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color); + const colorConfig = Object.entries(config).filter(([, config]) => config.theme ?? config.color); if (!colorConfig.length) { return null; @@ -81,7 +93,7 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { ${prefix} [data-chart=${id}] { ${colorConfig .map(([key, itemConfig]) => { - const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color; + const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ?? itemConfig.color; return color ? ` --color-${key}: ${color};` : null; }) .join("\n")} @@ -117,7 +129,10 @@ function ChartTooltipContent({ indicator?: "line" | "dot" | "dashed"; nameKey?: string; labelKey?: string; - }) { + } & Omit< + RechartsPrimitive.DefaultTooltipContentProps, + "accessibilityLayer" + >) { const { config } = useChart(); const tooltipLabel = React.useMemo(() => { @@ -126,12 +141,10 @@ function ChartTooltipContent({ } const [item] = payload; - const key = `${labelKey || item?.dataKey || item?.name || "value"}`; + const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`; const itemConfig = getPayloadConfigFromPayload(config, item, key); const value = - !labelKey && typeof label === "string" - ? config[label as keyof typeof config]?.label || label - : itemConfig?.label; + !labelKey && typeof label === "string" ? (config[label]?.label ?? label) : itemConfig?.label; if (labelFormatter) { return ( @@ -164,13 +177,13 @@ function ChartTooltipContent({ {payload .filter((item) => item.type !== "none") .map((item, index) => { - const key = `${nameKey || item.name || item.dataKey || "value"}`; + const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`; const itemConfig = getPayloadConfigFromPayload(config, item, key); - const indicatorColor = color || item.payload.fill || item.color; + const indicatorColor = color ?? item.payload?.fill ?? item.color; return (
svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", indicator === "dot" && "items-center", @@ -213,12 +226,14 @@ function ChartTooltipContent({
{nestLabel ? tooltipLabel : null} - {itemConfig?.label || item.name} + {itemConfig?.label ?? item.name}
- {item.value && ( + {item.value != null && ( - {item.value.toLocaleString()} + {typeof item.value === "number" + ? item.value.toLocaleString() + : String(item.value)} )}
@@ -240,11 +255,10 @@ function ChartLegendContent({ payload, verticalAlign = "bottom", nameKey, -}: React.ComponentProps<"div"> & - Pick & { - hideIcon?: boolean; - nameKey?: string; - }) { +}: React.ComponentProps<"div"> & { + hideIcon?: boolean; + nameKey?: string; +} & RechartsPrimitive.DefaultLegendContentProps) { const { config } = useChart(); if (!payload?.length) { @@ -261,13 +275,13 @@ function ChartLegendContent({ > {payload .filter((item) => item.type !== "none") - .map((item) => { - const key = `${nameKey || item.dataKey || "value"}`; + .map((item, index) => { + const key = `${nameKey ?? item.dataKey ?? "value"}`; const itemConfig = getPayloadConfigFromPayload(config, item, key); return (
svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground", )} @@ -312,7 +326,7 @@ function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string; } - return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]; + return configLabelKey in config ? config[configLabelKey] : config[key]; } export { diff --git a/packages/ui/src/components/checkbox.tsx b/packages/ui/src/components/checkbox.tsx index 0761ed1..bd1d2dc 100644 --- a/packages/ui/src/components/checkbox.tsx +++ b/packages/ui/src/components/checkbox.tsx @@ -1,3 +1,5 @@ +"use client"; + import * as React from "react"; import { Checkbox as CheckboxPrimitive } from "radix-ui"; diff --git a/packages/ui/src/components/collapsible.tsx b/packages/ui/src/components/collapsible.tsx index cf47226..80463d3 100644 --- a/packages/ui/src/components/collapsible.tsx +++ b/packages/ui/src/components/collapsible.tsx @@ -1,5 +1,3 @@ -"use client"; - import { Collapsible as CollapsiblePrimitive } from "radix-ui"; function Collapsible({ ...props }: React.ComponentProps) { diff --git a/packages/ui/src/components/context-menu.tsx b/packages/ui/src/components/context-menu.tsx index a91057d..4033b20 100644 --- a/packages/ui/src/components/context-menu.tsx +++ b/packages/ui/src/components/context-menu.tsx @@ -1,3 +1,5 @@ +"use client"; + import * as React from "react"; import { ContextMenu as ContextMenuPrimitive } from "radix-ui"; diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index 7e890dd..262b453 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -1,5 +1,3 @@ -"use client"; - import * as React from "react"; import { Dialog as DialogPrimitive } from "radix-ui"; @@ -53,7 +51,7 @@ function DialogContent({ ); diff --git a/packages/ui/src/components/drawer.tsx b/packages/ui/src/components/drawer.tsx index 6204005..62f2e1b 100644 --- a/packages/ui/src/components/drawer.tsx +++ b/packages/ui/src/components/drawer.tsx @@ -46,7 +46,7 @@ function DrawerContent({ ); diff --git a/packages/ui/src/components/empty.tsx b/packages/ui/src/components/empty.tsx index 663457f..4debee9 100644 --- a/packages/ui/src/components/empty.tsx +++ b/packages/ui/src/components/empty.tsx @@ -57,7 +57,11 @@ function EmptyMedia({ function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { return ( -
+
); } diff --git a/packages/ui/src/components/field.tsx b/packages/ui/src/components/field.tsx index c85f733..6e041f7 100644 --- a/packages/ui/src/components/field.tsx +++ b/packages/ui/src/components/field.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useMemo } from "react"; import { cva, type VariantProps } from "class-variance-authority"; diff --git a/packages/ui/src/components/input-group.tsx b/packages/ui/src/components/input-group.tsx index 3b320db..aa6b00e 100644 --- a/packages/ui/src/components/input-group.tsx +++ b/packages/ui/src/components/input-group.tsx @@ -1,3 +1,5 @@ +"use client"; + import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; diff --git a/packages/ui/src/components/input-otp.tsx b/packages/ui/src/components/input-otp.tsx index 6ff9297..4197357 100644 --- a/packages/ui/src/components/input-otp.tsx +++ b/packages/ui/src/components/input-otp.tsx @@ -1,5 +1,3 @@ -"use client"; - import * as React from "react"; import { OTPInput, OTPInputContext } from "input-otp"; diff --git a/packages/ui/src/components/item.tsx b/packages/ui/src/components/item.tsx index 552e932..de77a94 100644 --- a/packages/ui/src/components/item.tsx +++ b/packages/ui/src/components/item.tsx @@ -121,7 +121,7 @@ function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
); diff --git a/packages/ui/src/components/native-select.tsx b/packages/ui/src/components/native-select.tsx index 8bfa059..f98f21d 100644 --- a/packages/ui/src/components/native-select.tsx +++ b/packages/ui/src/components/native-select.tsx @@ -32,12 +32,24 @@ function NativeSelect({ className, size = "default", ...props }: NativeSelectPro ); } -function NativeSelectOption({ ...props }: React.ComponentProps<"option">) { - return ; + return ( + + ); } export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }; diff --git a/packages/ui/src/components/navigation-menu.tsx b/packages/ui/src/components/navigation-menu.tsx index fecef13..6e6ebbb 100644 --- a/packages/ui/src/components/navigation-menu.tsx +++ b/packages/ui/src/components/navigation-menu.tsx @@ -56,7 +56,7 @@ function NavigationMenuItem({ } const navigationMenuTriggerStyle = cva( - "group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-none bg-background px-2.5 py-1.5 text-xs font-medium transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-1 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus:bg-muted", + "group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-none px-2.5 py-1.5 text-xs font-medium transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-1 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus:bg-muted", ); function NavigationMenuTrigger({ diff --git a/packages/ui/src/components/popover.tsx b/packages/ui/src/components/popover.tsx index 7bf1c42..a7bdca7 100644 --- a/packages/ui/src/components/popover.tsx +++ b/packages/ui/src/components/popover.tsx @@ -49,7 +49,11 @@ function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) { return ( -
+
); } diff --git a/packages/ui/src/components/radio-group.tsx b/packages/ui/src/components/radio-group.tsx index 0ae861a..5b1ae06 100644 --- a/packages/ui/src/components/radio-group.tsx +++ b/packages/ui/src/components/radio-group.tsx @@ -1,3 +1,5 @@ +"use client"; + import * as React from "react"; import { RadioGroup as RadioGroupPrimitive } from "radix-ui"; diff --git a/packages/ui/src/components/resizable.tsx b/packages/ui/src/components/resizable.tsx index 78bc36c..0041e02 100644 --- a/packages/ui/src/components/resizable.tsx +++ b/packages/ui/src/components/resizable.tsx @@ -1,5 +1,3 @@ -"use client"; - import * as ResizablePrimitive from "react-resizable-panels"; import { cn } from "@open-learn/ui/lib/utils"; diff --git a/packages/ui/src/components/scroll-area.tsx b/packages/ui/src/components/scroll-area.tsx index 33af57f..9529df2 100644 --- a/packages/ui/src/components/scroll-area.tsx +++ b/packages/ui/src/components/scroll-area.tsx @@ -1,3 +1,5 @@ +"use client"; + import * as React from "react"; import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"; diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index 444a6be..36c2005 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -1,5 +1,3 @@ -"use client"; - import * as React from "react"; import { Select as SelectPrimitive } from "radix-ui"; diff --git a/packages/ui/src/components/separator.tsx b/packages/ui/src/components/separator.tsx index 0c9039b..ad9cee0 100644 --- a/packages/ui/src/components/separator.tsx +++ b/packages/ui/src/components/separator.tsx @@ -1,3 +1,5 @@ +"use client"; + import * as React from "react"; import { Separator as SeparatorPrimitive } from "radix-ui"; diff --git a/packages/ui/src/components/sheet.tsx b/packages/ui/src/components/sheet.tsx index 4635e7c..fb7e338 100644 --- a/packages/ui/src/components/sheet.tsx +++ b/packages/ui/src/components/sheet.tsx @@ -1,3 +1,5 @@ +"use client"; + import * as React from "react"; import { Dialog as SheetPrimitive } from "radix-ui"; @@ -54,7 +56,7 @@ function SheetContent({ data-slot="sheet-content" data-side={side} className={cn( - "fixed z-50 flex flex-col bg-background bg-clip-padding text-xs/relaxed shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10", + "fixed z-50 flex flex-col bg-popover bg-clip-padding text-xs/relaxed text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10", className, )} {...props} @@ -97,7 +99,7 @@ function SheetTitle({ className, ...props }: React.ComponentProps ); diff --git a/packages/ui/src/components/switch.tsx b/packages/ui/src/components/switch.tsx index 46de94a..3294230 100644 --- a/packages/ui/src/components/switch.tsx +++ b/packages/ui/src/components/switch.tsx @@ -1,5 +1,3 @@ -"use client"; - import * as React from "react"; import { Switch as SwitchPrimitive } from "radix-ui"; diff --git a/packages/ui/src/components/table.tsx b/packages/ui/src/components/table.tsx index e88c471..d6bb9fb 100644 --- a/packages/ui/src/components/table.tsx +++ b/packages/ui/src/components/table.tsx @@ -43,7 +43,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {