Upload 86 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +29 -0
- Dockerfile +51 -0
- components.json +21 -0
- eslint.config.mjs +50 -0
- next.config.ts +12 -0
- package.json +100 -0
- postcss.config.mjs +5 -0
- prisma/schema.prisma +80 -0
- public/logo.svg +29 -0
- public/robots.txt +14 -0
- src/app/api/activity/route.ts +24 -0
- src/app/api/auth/login/route.ts +89 -0
- src/app/api/auth/logout/route.ts +30 -0
- src/app/api/auth/me/route.ts +33 -0
- src/app/api/auth/password/route.ts +81 -0
- src/app/api/auth/signup/route.ts +98 -0
- src/app/api/chat/route.ts +215 -0
- src/app/api/invoices/export/csv/route.ts +89 -0
- src/app/api/invoices/export/json/route.ts +63 -0
- src/app/api/invoices/export/xlsx/route.ts +144 -0
- src/app/api/invoices/route.ts +103 -0
- src/app/api/invoices/update/route.ts +45 -0
- src/app/api/route.ts +5 -0
- src/app/api/seed/route.ts +364 -0
- src/app/globals.css +2040 -0
- src/app/layout.tsx +59 -0
- src/app/page.tsx +0 -0
- src/components/ui/accordion.tsx +66 -0
- src/components/ui/alert-dialog.tsx +157 -0
- src/components/ui/alert.tsx +66 -0
- src/components/ui/aspect-ratio.tsx +11 -0
- src/components/ui/avatar.tsx +53 -0
- src/components/ui/badge.tsx +46 -0
- src/components/ui/breadcrumb.tsx +109 -0
- src/components/ui/button.tsx +59 -0
- src/components/ui/calendar.tsx +213 -0
- src/components/ui/card.tsx +92 -0
- src/components/ui/carousel.tsx +241 -0
- src/components/ui/chart.tsx +353 -0
- src/components/ui/checkbox.tsx +32 -0
- src/components/ui/collapsible.tsx +33 -0
- src/components/ui/command.tsx +184 -0
- src/components/ui/context-menu.tsx +252 -0
- src/components/ui/dialog.tsx +143 -0
- src/components/ui/drawer.tsx +135 -0
- src/components/ui/dropdown-menu.tsx +257 -0
- src/components/ui/form.tsx +167 -0
- src/components/ui/hover-card.tsx +44 -0
- src/components/ui/input-otp.tsx +77 -0
- src/components/ui/input.tsx +21 -0
.dockerignore
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
.next
|
| 3 |
+
.git
|
| 4 |
+
.gitignore
|
| 5 |
+
db/
|
| 6 |
+
*.db
|
| 7 |
+
*.log
|
| 8 |
+
dev.log
|
| 9 |
+
server.log
|
| 10 |
+
tool-results/
|
| 11 |
+
download/
|
| 12 |
+
hf-deploy/
|
| 13 |
+
examples/
|
| 14 |
+
agent-ctx/
|
| 15 |
+
vercel/
|
| 16 |
+
--output
|
| 17 |
+
.env
|
| 18 |
+
*.png
|
| 19 |
+
*.jpg
|
| 20 |
+
*.jpeg
|
| 21 |
+
claude*
|
| 22 |
+
.z-ai-config
|
| 23 |
+
.claude
|
| 24 |
+
Caddyfile
|
| 25 |
+
hf_*.py
|
| 26 |
+
hf_*.json
|
| 27 |
+
bun.lock
|
| 28 |
+
README.md
|
| 29 |
+
worklog.md
|
Dockerfile
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ── Stage 1: Install dependencies ─────────────────────────────────────
|
| 2 |
+
FROM node:20-alpine AS deps
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Copy package.json AND prisma schema (postinstall needs schema)
|
| 6 |
+
COPY package.json ./
|
| 7 |
+
COPY prisma ./prisma/
|
| 8 |
+
|
| 9 |
+
# Install deps without scripts (prisma generate runs in builder stage)
|
| 10 |
+
RUN npm install --ignore-scripts 2>/dev/null
|
| 11 |
+
|
| 12 |
+
# ── Stage 2: Build the Next.js app ────────────────────────────────────
|
| 13 |
+
FROM node:20-alpine AS builder
|
| 14 |
+
WORKDIR /app
|
| 15 |
+
|
| 16 |
+
COPY --from=deps /app/node_modules ./node_modules
|
| 17 |
+
COPY . .
|
| 18 |
+
|
| 19 |
+
# Generate Prisma client then build
|
| 20 |
+
RUN npx prisma generate
|
| 21 |
+
RUN npm run build
|
| 22 |
+
|
| 23 |
+
# ── Stage 3: Production ───────────────────────────────────────────────
|
| 24 |
+
FROM node:20-alpine AS runner
|
| 25 |
+
WORKDIR /app
|
| 26 |
+
|
| 27 |
+
ENV NODE_ENV=production
|
| 28 |
+
ENV PORT=7860
|
| 29 |
+
ENV HOSTNAME="0.0.0.0"
|
| 30 |
+
|
| 31 |
+
# Create db directory for SQLite
|
| 32 |
+
RUN mkdir -p /app/db
|
| 33 |
+
|
| 34 |
+
# Copy standalone output
|
| 35 |
+
COPY --from=builder /app/.next/standalone ./
|
| 36 |
+
COPY --from=builder /app/.next/static ./.next/static
|
| 37 |
+
COPY --from=builder /app/public ./public
|
| 38 |
+
|
| 39 |
+
# Copy Prisma schema + engine for db push on startup
|
| 40 |
+
COPY --from=builder /app/prisma ./prisma
|
| 41 |
+
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
| 42 |
+
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
| 43 |
+
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
|
| 44 |
+
|
| 45 |
+
EXPOSE 7860
|
| 46 |
+
|
| 47 |
+
# Entrypoint: init DB then start server
|
| 48 |
+
RUN printf '#!/bin/sh\nnpx prisma db push --skip-generate 2>&1 || true\nexec node /app/server.js\n' > /app/entrypoint.sh \
|
| 49 |
+
&& chmod +x /app/entrypoint.sh
|
| 50 |
+
|
| 51 |
+
CMD ["/app/entrypoint.sh"]
|
components.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://ui.shadcn.com/schema.json",
|
| 3 |
+
"style": "new-york",
|
| 4 |
+
"rsc": true,
|
| 5 |
+
"tsx": true,
|
| 6 |
+
"tailwind": {
|
| 7 |
+
"config": "",
|
| 8 |
+
"css": "src/app/globals.css",
|
| 9 |
+
"baseColor": "neutral",
|
| 10 |
+
"cssVariables": true,
|
| 11 |
+
"prefix": ""
|
| 12 |
+
},
|
| 13 |
+
"aliases": {
|
| 14 |
+
"components": "@/components",
|
| 15 |
+
"utils": "@/lib/utils",
|
| 16 |
+
"ui": "@/components/ui",
|
| 17 |
+
"lib": "@/lib",
|
| 18 |
+
"hooks": "@/hooks"
|
| 19 |
+
},
|
| 20 |
+
"iconLibrary": "lucide"
|
| 21 |
+
}
|
eslint.config.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
| 2 |
+
import nextTypescript from "eslint-config-next/typescript";
|
| 3 |
+
import { dirname } from "path";
|
| 4 |
+
import { fileURLToPath } from "url";
|
| 5 |
+
|
| 6 |
+
const __filename = fileURLToPath(import.meta.url);
|
| 7 |
+
const __dirname = dirname(__filename);
|
| 8 |
+
|
| 9 |
+
const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, {
|
| 10 |
+
rules: {
|
| 11 |
+
// TypeScript rules
|
| 12 |
+
"@typescript-eslint/no-explicit-any": "off",
|
| 13 |
+
"@typescript-eslint/no-unused-vars": "off",
|
| 14 |
+
"@typescript-eslint/no-non-null-assertion": "off",
|
| 15 |
+
"@typescript-eslint/ban-ts-comment": "off",
|
| 16 |
+
"@typescript-eslint/prefer-as-const": "off",
|
| 17 |
+
"@typescript-eslint/no-unused-disable-directive": "off",
|
| 18 |
+
|
| 19 |
+
// React rules
|
| 20 |
+
"react-hooks/exhaustive-deps": "off",
|
| 21 |
+
"react-hooks/purity": "off",
|
| 22 |
+
"react/no-unescaped-entities": "off",
|
| 23 |
+
"react/display-name": "off",
|
| 24 |
+
"react/prop-types": "off",
|
| 25 |
+
"react-compiler/react-compiler": "off",
|
| 26 |
+
|
| 27 |
+
// Next.js rules
|
| 28 |
+
"@next/next/no-img-element": "off",
|
| 29 |
+
"@next/next/no-html-link-for-pages": "off",
|
| 30 |
+
|
| 31 |
+
// General JavaScript rules
|
| 32 |
+
"prefer-const": "off",
|
| 33 |
+
"no-unused-vars": "off",
|
| 34 |
+
"no-console": "off",
|
| 35 |
+
"no-debugger": "off",
|
| 36 |
+
"no-empty": "off",
|
| 37 |
+
"no-irregular-whitespace": "off",
|
| 38 |
+
"no-case-declarations": "off",
|
| 39 |
+
"no-fallthrough": "off",
|
| 40 |
+
"no-mixed-spaces-and-tabs": "off",
|
| 41 |
+
"no-redeclare": "off",
|
| 42 |
+
"no-undef": "off",
|
| 43 |
+
"no-unreachable": "off",
|
| 44 |
+
"no-useless-escape": "off",
|
| 45 |
+
},
|
| 46 |
+
}, {
|
| 47 |
+
ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts", "examples/**", "skills"]
|
| 48 |
+
}];
|
| 49 |
+
|
| 50 |
+
export default eslintConfig;
|
next.config.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { NextConfig } from "next";
|
| 2 |
+
|
| 3 |
+
const nextConfig: NextConfig = {
|
| 4 |
+
output: "standalone",
|
| 5 |
+
/* config options here */
|
| 6 |
+
typescript: {
|
| 7 |
+
ignoreBuildErrors: true,
|
| 8 |
+
},
|
| 9 |
+
reactStrictMode: false,
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
export default nextConfig;
|
package.json
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "nextjs_tailwind_shadcn_ts",
|
| 3 |
+
"version": "0.2.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev -p 3000 2>&1 | tee dev.log",
|
| 7 |
+
"build": "next build && cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/",
|
| 8 |
+
"start": "NODE_ENV=production node .next/standalone/server.js",
|
| 9 |
+
"start:bun": "NODE_ENV=production bun .next/standalone/server.js",
|
| 10 |
+
"lint": "eslint .",
|
| 11 |
+
"db:push": "prisma db push",
|
| 12 |
+
"db:generate": "prisma generate",
|
| 13 |
+
"db:migrate": "prisma migrate dev",
|
| 14 |
+
"db:reset": "prisma migrate reset",
|
| 15 |
+
"postinstall": "prisma generate",
|
| 16 |
+
"koyeb:build": "prisma generate && next build && cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/",
|
| 17 |
+
"koyeb:start": "npx prisma db push --skip-generate 2>/dev/null; NODE_ENV=production node .next/standalone/server.js"
|
| 18 |
+
},
|
| 19 |
+
"dependencies": {
|
| 20 |
+
"@dnd-kit/core": "^6.3.1",
|
| 21 |
+
"@dnd-kit/sortable": "^10.0.0",
|
| 22 |
+
"@dnd-kit/utilities": "^3.2.2",
|
| 23 |
+
"@hookform/resolvers": "^5.1.1",
|
| 24 |
+
"@mdxeditor/editor": "^3.39.1",
|
| 25 |
+
"@prisma/client": "^6.11.1",
|
| 26 |
+
"@radix-ui/react-accordion": "^1.2.11",
|
| 27 |
+
"@radix-ui/react-alert-dialog": "^1.1.14",
|
| 28 |
+
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
| 29 |
+
"@radix-ui/react-avatar": "^1.1.10",
|
| 30 |
+
"@radix-ui/react-checkbox": "^1.3.2",
|
| 31 |
+
"@radix-ui/react-collapsible": "^1.1.11",
|
| 32 |
+
"@radix-ui/react-context-menu": "^2.2.15",
|
| 33 |
+
"@radix-ui/react-dialog": "^1.1.14",
|
| 34 |
+
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
| 35 |
+
"@radix-ui/react-hover-card": "^1.1.14",
|
| 36 |
+
"@radix-ui/react-label": "^2.1.7",
|
| 37 |
+
"@radix-ui/react-menubar": "^1.1.15",
|
| 38 |
+
"@radix-ui/react-navigation-menu": "^1.2.13",
|
| 39 |
+
"@radix-ui/react-popover": "^1.1.14",
|
| 40 |
+
"@radix-ui/react-progress": "^1.1.7",
|
| 41 |
+
"@radix-ui/react-radio-group": "^1.3.7",
|
| 42 |
+
"@radix-ui/react-scroll-area": "^1.2.9",
|
| 43 |
+
"@radix-ui/react-select": "^2.2.5",
|
| 44 |
+
"@radix-ui/react-separator": "^1.1.7",
|
| 45 |
+
"@radix-ui/react-slider": "^1.3.5",
|
| 46 |
+
"@radix-ui/react-slot": "^1.2.3",
|
| 47 |
+
"@radix-ui/react-switch": "^1.2.5",
|
| 48 |
+
"@radix-ui/react-tabs": "^1.1.12",
|
| 49 |
+
"@radix-ui/react-toast": "^1.2.14",
|
| 50 |
+
"@radix-ui/react-toggle": "^1.1.9",
|
| 51 |
+
"@radix-ui/react-toggle-group": "^1.1.10",
|
| 52 |
+
"@radix-ui/react-tooltip": "^1.2.7",
|
| 53 |
+
"@reactuses/core": "^6.0.5",
|
| 54 |
+
"@tanstack/react-query": "^5.82.0",
|
| 55 |
+
"@tanstack/react-table": "^8.21.3",
|
| 56 |
+
"bcryptjs": "^3.0.3",
|
| 57 |
+
"class-variance-authority": "^0.7.1",
|
| 58 |
+
"clsx": "^2.1.1",
|
| 59 |
+
"cmdk": "^1.1.1",
|
| 60 |
+
"date-fns": "^4.1.0",
|
| 61 |
+
"embla-carousel-react": "^8.6.0",
|
| 62 |
+
"exceljs": "^4.4.0",
|
| 63 |
+
"framer-motion": "^12.23.2",
|
| 64 |
+
"input-otp": "^1.4.2",
|
| 65 |
+
"lucide-react": "^0.525.0",
|
| 66 |
+
"next": "^16.1.1",
|
| 67 |
+
"next-auth": "^4.24.11",
|
| 68 |
+
"next-intl": "^4.3.4",
|
| 69 |
+
"next-themes": "^0.4.6",
|
| 70 |
+
"prisma": "^6.11.1",
|
| 71 |
+
"react": "^19.0.0",
|
| 72 |
+
"react-day-picker": "^9.8.0",
|
| 73 |
+
"react-dom": "^19.0.0",
|
| 74 |
+
"react-hook-form": "^7.60.0",
|
| 75 |
+
"react-markdown": "^10.1.0",
|
| 76 |
+
"react-resizable-panels": "^3.0.3",
|
| 77 |
+
"react-syntax-highlighter": "^15.6.1",
|
| 78 |
+
"recharts": "^2.15.4",
|
| 79 |
+
"sharp": "^0.34.3",
|
| 80 |
+
"sonner": "^2.0.6",
|
| 81 |
+
"tailwind-merge": "^3.3.1",
|
| 82 |
+
"tailwindcss-animate": "^1.0.7",
|
| 83 |
+
"uuid": "^11.1.0",
|
| 84 |
+
"vaul": "^1.1.2",
|
| 85 |
+
"zod": "^4.4.3",
|
| 86 |
+
"zustand": "^5.0.6"
|
| 87 |
+
},
|
| 88 |
+
"devDependencies": {
|
| 89 |
+
"@tailwindcss/postcss": "^4",
|
| 90 |
+
"@types/bcryptjs": "^3.0.0",
|
| 91 |
+
"@types/react": "^19",
|
| 92 |
+
"@types/react-dom": "^19",
|
| 93 |
+
"@types/uuid": "^11.0.0",
|
| 94 |
+
"eslint": "^9",
|
| 95 |
+
"eslint-config-next": "^16.1.1",
|
| 96 |
+
"tailwindcss": "^4",
|
| 97 |
+
"tw-animate-css": "^1.3.5",
|
| 98 |
+
"typescript": "^5"
|
| 99 |
+
}
|
| 100 |
+
}
|
postcss.config.mjs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = {
|
| 2 |
+
plugins: ["@tailwindcss/postcss"],
|
| 3 |
+
};
|
| 4 |
+
|
| 5 |
+
export default config;
|
prisma/schema.prisma
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
generator client {
|
| 2 |
+
provider = "prisma-client-js"
|
| 3 |
+
}
|
| 4 |
+
|
| 5 |
+
datasource db {
|
| 6 |
+
provider = "sqlite"
|
| 7 |
+
url = "file:../db/custom.db"
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
model User {
|
| 11 |
+
id String @id @default(cuid())
|
| 12 |
+
email String @unique
|
| 13 |
+
name String
|
| 14 |
+
password String
|
| 15 |
+
plan String @default("free")
|
| 16 |
+
stripeCid String?
|
| 17 |
+
apiKey String?
|
| 18 |
+
createdAt DateTime @default(now())
|
| 19 |
+
updatedAt DateTime @updatedAt
|
| 20 |
+
|
| 21 |
+
sessions Session[]
|
| 22 |
+
invoices Invoice[]
|
| 23 |
+
activityLogs ActivityLog[]
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
model Session {
|
| 27 |
+
id String @id @default(cuid())
|
| 28 |
+
token String @unique
|
| 29 |
+
userId String
|
| 30 |
+
expiresAt DateTime
|
| 31 |
+
createdAt DateTime @default(now())
|
| 32 |
+
|
| 33 |
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
model Invoice {
|
| 37 |
+
id String @id @default(cuid())
|
| 38 |
+
userId String
|
| 39 |
+
filename String?
|
| 40 |
+
vendor String?
|
| 41 |
+
invNumber String?
|
| 42 |
+
invDate String?
|
| 43 |
+
dueDate String?
|
| 44 |
+
amount Float?
|
| 45 |
+
vatAmount Float?
|
| 46 |
+
total Float?
|
| 47 |
+
currency String @default("USD")
|
| 48 |
+
status String @default("done")
|
| 49 |
+
isDuplicate Boolean @default(false)
|
| 50 |
+
confidence Float?
|
| 51 |
+
rawJson String?
|
| 52 |
+
createdAt DateTime @default(now())
|
| 53 |
+
updatedAt DateTime @updatedAt
|
| 54 |
+
|
| 55 |
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
model RateLimit {
|
| 59 |
+
id String @id @default(cuid())
|
| 60 |
+
key String @unique
|
| 61 |
+
count Int @default(0)
|
| 62 |
+
resetAt DateTime
|
| 63 |
+
createdAt DateTime @default(now())
|
| 64 |
+
updatedAt DateTime @updatedAt
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
model ActivityLog {
|
| 68 |
+
id String @id @default(cuid())
|
| 69 |
+
userId String
|
| 70 |
+
type String
|
| 71 |
+
title String
|
| 72 |
+
description String
|
| 73 |
+
metadata String?
|
| 74 |
+
createdAt DateTime @default(now())
|
| 75 |
+
updatedAt DateTime @updatedAt
|
| 76 |
+
|
| 77 |
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
| 78 |
+
|
| 79 |
+
@@index([userId])
|
| 80 |
+
}
|
public/logo.svg
ADDED
|
|
public/robots.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
User-agent: Googlebot
|
| 2 |
+
Allow: /
|
| 3 |
+
|
| 4 |
+
User-agent: Bingbot
|
| 5 |
+
Allow: /
|
| 6 |
+
|
| 7 |
+
User-agent: Twitterbot
|
| 8 |
+
Allow: /
|
| 9 |
+
|
| 10 |
+
User-agent: facebookexternalhit
|
| 11 |
+
Allow: /
|
| 12 |
+
|
| 13 |
+
User-agent: *
|
| 14 |
+
Allow: /
|
src/app/api/activity/route.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import { db } from '@/lib/db';
|
| 3 |
+
import { getCookieToken, getSessionUser } from '@/lib/auth';
|
| 4 |
+
|
| 5 |
+
export async function GET(req: NextRequest) {
|
| 6 |
+
try {
|
| 7 |
+
const token = getCookieToken(req);
|
| 8 |
+
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
| 9 |
+
|
| 10 |
+
const user = await getSessionUser(token);
|
| 11 |
+
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
| 12 |
+
|
| 13 |
+
const logs = await db.activityLog.findMany({
|
| 14 |
+
where: { userId: user.id },
|
| 15 |
+
orderBy: { createdAt: 'desc' },
|
| 16 |
+
take: 50,
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
return NextResponse.json({ activities: logs });
|
| 20 |
+
} catch (error) {
|
| 21 |
+
console.error('Activity fetch error:', error);
|
| 22 |
+
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
| 23 |
+
}
|
| 24 |
+
}
|
src/app/api/auth/login/route.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import { db } from '@/lib/db';
|
| 3 |
+
import { loginSchema, getClientIp } from '@/lib/validation';
|
| 4 |
+
import {
|
| 5 |
+
verifyPassword,
|
| 6 |
+
createSession,
|
| 7 |
+
SESSION_COOKIE_NAME,
|
| 8 |
+
} from '@/lib/auth';
|
| 9 |
+
import { authRateLimit } from '@/lib/rate-limit';
|
| 10 |
+
import { logActivity } from '@/lib/activity';
|
| 11 |
+
|
| 12 |
+
export async function POST(request: NextRequest) {
|
| 13 |
+
try {
|
| 14 |
+
// ── Rate Limit ────────────────────────────────────────────────────
|
| 15 |
+
const ip = getClientIp(request);
|
| 16 |
+
const { allowed, retryAfterMs } = await authRateLimit(`auth:${ip}`);
|
| 17 |
+
if (!allowed) {
|
| 18 |
+
return NextResponse.json(
|
| 19 |
+
{ error: 'Too many login attempts. Please try again later.' },
|
| 20 |
+
{
|
| 21 |
+
status: 429,
|
| 22 |
+
headers: { 'Retry-After': String(Math.ceil(retryAfterMs / 1000)) },
|
| 23 |
+
}
|
| 24 |
+
);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// ── Parse & Validate Body ─────────────────────────────────────────
|
| 28 |
+
const body = await request.json();
|
| 29 |
+
const parsed = loginSchema.safeParse(body);
|
| 30 |
+
if (!parsed.success) {
|
| 31 |
+
return NextResponse.json(
|
| 32 |
+
{ error: parsed.error.issues[0].message },
|
| 33 |
+
{ status: 400 }
|
| 34 |
+
);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const { email, password } = parsed.data;
|
| 38 |
+
|
| 39 |
+
// ── Find User ─────────────────────────────────────────────────────
|
| 40 |
+
const user = await db.user.findUnique({ where: { email } });
|
| 41 |
+
if (!user) {
|
| 42 |
+
return NextResponse.json(
|
| 43 |
+
{ error: 'Invalid email or password' },
|
| 44 |
+
{ status: 401 }
|
| 45 |
+
);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// ── Verify Password ───────────────────────────────────────────────
|
| 49 |
+
const valid = await verifyPassword(password, user.password);
|
| 50 |
+
if (!valid) {
|
| 51 |
+
return NextResponse.json(
|
| 52 |
+
{ error: 'Invalid email or password' },
|
| 53 |
+
{ status: 401 }
|
| 54 |
+
);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// ── Create Session ────────────────────────────────────────────────
|
| 58 |
+
const token = await createSession(user.id);
|
| 59 |
+
|
| 60 |
+
// ── Set Cookie & Respond ──────────────────────────────────────────
|
| 61 |
+
const response = NextResponse.json({
|
| 62 |
+
user: {
|
| 63 |
+
id: user.id,
|
| 64 |
+
email: user.email,
|
| 65 |
+
name: user.name,
|
| 66 |
+
plan: user.plan,
|
| 67 |
+
createdAt: user.createdAt,
|
| 68 |
+
},
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
response.cookies.set(SESSION_COOKIE_NAME, token, {
|
| 72 |
+
httpOnly: true,
|
| 73 |
+
secure: true,
|
| 74 |
+
sameSite: 'lax',
|
| 75 |
+
path: '/',
|
| 76 |
+
maxAge: 7 * 24 * 60 * 60, // 7 days
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
logActivity(user.id, 'login', 'Logged in', 'Signed in successfully');
|
| 80 |
+
|
| 81 |
+
return response;
|
| 82 |
+
} catch (error) {
|
| 83 |
+
console.error('[LOGIN_ERROR]', error);
|
| 84 |
+
return NextResponse.json(
|
| 85 |
+
{ error: 'Internal server error' },
|
| 86 |
+
{ status: 500 }
|
| 87 |
+
);
|
| 88 |
+
}
|
| 89 |
+
}
|
src/app/api/auth/logout/route.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import { getCookieToken, deleteSession, SESSION_COOKIE_NAME } from '@/lib/auth';
|
| 3 |
+
|
| 4 |
+
export async function POST(request: NextRequest) {
|
| 5 |
+
try {
|
| 6 |
+
const token = getCookieToken(request);
|
| 7 |
+
|
| 8 |
+
if (token) {
|
| 9 |
+
await deleteSession(token);
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
const response = NextResponse.json({ success: true });
|
| 13 |
+
|
| 14 |
+
response.cookies.set(SESSION_COOKIE_NAME, '', {
|
| 15 |
+
httpOnly: true,
|
| 16 |
+
secure: true,
|
| 17 |
+
sameSite: 'lax',
|
| 18 |
+
path: '/',
|
| 19 |
+
maxAge: 0,
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
return response;
|
| 23 |
+
} catch (error) {
|
| 24 |
+
console.error('[LOGOUT_ERROR]', error);
|
| 25 |
+
return NextResponse.json(
|
| 26 |
+
{ error: 'Internal server error' },
|
| 27 |
+
{ status: 500 }
|
| 28 |
+
);
|
| 29 |
+
}
|
| 30 |
+
}
|
src/app/api/auth/me/route.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import { getCookieToken, getSessionUser } from '@/lib/auth';
|
| 3 |
+
|
| 4 |
+
export async function GET(request: NextRequest) {
|
| 5 |
+
try {
|
| 6 |
+
const token = getCookieToken(request);
|
| 7 |
+
if (!token) {
|
| 8 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
const user = await getSessionUser(token);
|
| 12 |
+
if (!user) {
|
| 13 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
return NextResponse.json({
|
| 17 |
+
user: {
|
| 18 |
+
id: user.id,
|
| 19 |
+
email: user.email,
|
| 20 |
+
name: user.name,
|
| 21 |
+
plan: user.plan,
|
| 22 |
+
apiKey: user.apiKey,
|
| 23 |
+
createdAt: user.createdAt,
|
| 24 |
+
},
|
| 25 |
+
});
|
| 26 |
+
} catch (error) {
|
| 27 |
+
console.error('[ME_ERROR]', error);
|
| 28 |
+
return NextResponse.json(
|
| 29 |
+
{ error: 'Internal server error' },
|
| 30 |
+
{ status: 500 }
|
| 31 |
+
);
|
| 32 |
+
}
|
| 33 |
+
}
|
src/app/api/auth/password/route.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import { db } from '@/lib/db';
|
| 3 |
+
import { changePasswordSchema, getClientIp } from '@/lib/validation';
|
| 4 |
+
import { getSessionUser, verifyPassword, hashPassword, getCookieToken } from '@/lib/auth';
|
| 5 |
+
import { rateLimit } from '@/lib/rate-limit';
|
| 6 |
+
import { logActivity } from '@/lib/activity';
|
| 7 |
+
|
| 8 |
+
export async function POST(request: NextRequest) {
|
| 9 |
+
try {
|
| 10 |
+
// ── Rate Limit ────────────────────────────────────────────────────
|
| 11 |
+
const ip = getClientIp(request);
|
| 12 |
+
const { allowed, retryAfterMs } = await rateLimit(`password:${ip}`, 3, 60_000);
|
| 13 |
+
if (!allowed) {
|
| 14 |
+
return NextResponse.json(
|
| 15 |
+
{ error: 'Too many password change attempts. Please try again later.' },
|
| 16 |
+
{
|
| 17 |
+
status: 429,
|
| 18 |
+
headers: { 'Retry-After': String(Math.ceil(retryAfterMs / 1000)) },
|
| 19 |
+
}
|
| 20 |
+
);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
// ── Authenticate User ─────────────────────────────────────────────
|
| 24 |
+
const token = getCookieToken(request);
|
| 25 |
+
const user = await getSessionUser(token || '');
|
| 26 |
+
if (!user) {
|
| 27 |
+
return NextResponse.json(
|
| 28 |
+
{ error: 'Authentication required' },
|
| 29 |
+
{ status: 401 }
|
| 30 |
+
);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
// ── Parse & Validate Body ─────────────────────────────────────────
|
| 34 |
+
const body = await request.json();
|
| 35 |
+
const parsed = changePasswordSchema.safeParse(body);
|
| 36 |
+
if (!parsed.success) {
|
| 37 |
+
return NextResponse.json(
|
| 38 |
+
{ error: parsed.error.issues[0].message },
|
| 39 |
+
{ status: 400 }
|
| 40 |
+
);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const { currentPassword, newPassword } = parsed.data;
|
| 44 |
+
|
| 45 |
+
// ── Verify Current Password ───────────────────────────────────────
|
| 46 |
+
const valid = await verifyPassword(currentPassword, user.password);
|
| 47 |
+
if (!valid) {
|
| 48 |
+
return NextResponse.json(
|
| 49 |
+
{ error: 'Invalid credentials' },
|
| 50 |
+
{ status: 401 }
|
| 51 |
+
);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// ── Hash New Password ─────────────────────────────────────────────
|
| 55 |
+
const hashedPassword = await hashPassword(newPassword);
|
| 56 |
+
|
| 57 |
+
// ── Update User Password ──────────────────────────────────────────
|
| 58 |
+
await db.user.update({
|
| 59 |
+
where: { id: user.id },
|
| 60 |
+
data: { password: hashedPassword },
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
// ── Invalidate All Sessions (force re-login) ──────────────────────
|
| 64 |
+
await db.session.deleteMany({
|
| 65 |
+
where: { userId: user.id },
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
logActivity(user.id, 'password_change', 'Password changed', 'Account password was updated');
|
| 69 |
+
|
| 70 |
+
return NextResponse.json({
|
| 71 |
+
success: true,
|
| 72 |
+
message: 'Password updated successfully',
|
| 73 |
+
});
|
| 74 |
+
} catch (error) {
|
| 75 |
+
console.error('[PASSWORD_CHANGE_ERROR]', error);
|
| 76 |
+
return NextResponse.json(
|
| 77 |
+
{ error: 'Internal server error' },
|
| 78 |
+
{ status: 500 }
|
| 79 |
+
);
|
| 80 |
+
}
|
| 81 |
+
}
|
src/app/api/auth/signup/route.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import { db } from '@/lib/db';
|
| 3 |
+
import { signupSchema, getClientIp } from '@/lib/validation';
|
| 4 |
+
import {
|
| 5 |
+
hashPassword,
|
| 6 |
+
createSession,
|
| 7 |
+
generateApiKey,
|
| 8 |
+
SESSION_COOKIE_NAME,
|
| 9 |
+
} from '@/lib/auth';
|
| 10 |
+
import { authRateLimit } from '@/lib/rate-limit';
|
| 11 |
+
import { logActivity } from '@/lib/activity';
|
| 12 |
+
|
| 13 |
+
export async function POST(request: NextRequest) {
|
| 14 |
+
try {
|
| 15 |
+
// ── Rate Limit ────────────────────────────────────────────────────
|
| 16 |
+
const ip = getClientIp(request);
|
| 17 |
+
const { allowed, retryAfterMs } = await authRateLimit(`auth:${ip}`);
|
| 18 |
+
if (!allowed) {
|
| 19 |
+
return NextResponse.json(
|
| 20 |
+
{ error: 'Too many signup attempts. Please try again later.' },
|
| 21 |
+
{
|
| 22 |
+
status: 429,
|
| 23 |
+
headers: { 'Retry-After': String(Math.ceil(retryAfterMs / 1000)) },
|
| 24 |
+
}
|
| 25 |
+
);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// ── Parse & Validate Body ─────────────────────────────────────────
|
| 29 |
+
const body = await request.json();
|
| 30 |
+
const parsed = signupSchema.safeParse(body);
|
| 31 |
+
if (!parsed.success) {
|
| 32 |
+
return NextResponse.json(
|
| 33 |
+
{ error: parsed.error.issues[0].message },
|
| 34 |
+
{ status: 400 }
|
| 35 |
+
);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const { name, email, password } = parsed.data;
|
| 39 |
+
|
| 40 |
+
// ── Check if Email Exists ─────────────────────────────────────────
|
| 41 |
+
const existing = await db.user.findUnique({ where: { email } });
|
| 42 |
+
if (existing) {
|
| 43 |
+
return NextResponse.json(
|
| 44 |
+
{ error: 'An account with this email already exists' },
|
| 45 |
+
{ status: 409 }
|
| 46 |
+
);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// ── Hash Password & Generate API Key ──────────────────────────────
|
| 50 |
+
const [hashedPassword, apiKey] = await Promise.all([
|
| 51 |
+
hashPassword(password),
|
| 52 |
+
generateApiKey(),
|
| 53 |
+
]);
|
| 54 |
+
|
| 55 |
+
// ── Create User ───────────────────────────────────────────────────
|
| 56 |
+
const user = await db.user.create({
|
| 57 |
+
data: {
|
| 58 |
+
name,
|
| 59 |
+
email,
|
| 60 |
+
password: hashedPassword,
|
| 61 |
+
apiKey,
|
| 62 |
+
plan: 'free',
|
| 63 |
+
},
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
// ── Create Session ────────────────────────────────────────────────
|
| 67 |
+
const token = await createSession(user.id);
|
| 68 |
+
|
| 69 |
+
// ── Set Cookie & Respond ──────────────────────────────────────────
|
| 70 |
+
const response = NextResponse.json({
|
| 71 |
+
user: {
|
| 72 |
+
id: user.id,
|
| 73 |
+
email: user.email,
|
| 74 |
+
name: user.name,
|
| 75 |
+
plan: user.plan,
|
| 76 |
+
createdAt: user.createdAt,
|
| 77 |
+
},
|
| 78 |
+
});
|
| 79 |
+
|
| 80 |
+
response.cookies.set(SESSION_COOKIE_NAME, token, {
|
| 81 |
+
httpOnly: true,
|
| 82 |
+
secure: true,
|
| 83 |
+
sameSite: 'lax',
|
| 84 |
+
path: '/',
|
| 85 |
+
maxAge: 7 * 24 * 60 * 60, // 7 days
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
logActivity(user.id, 'signup', 'Account created', user.email);
|
| 89 |
+
|
| 90 |
+
return response;
|
| 91 |
+
} catch (error) {
|
| 92 |
+
console.error('[SIGNUP_ERROR]', error);
|
| 93 |
+
return NextResponse.json(
|
| 94 |
+
{ error: 'Internal server error' },
|
| 95 |
+
{ status: 500 }
|
| 96 |
+
);
|
| 97 |
+
}
|
| 98 |
+
}
|
src/app/api/chat/route.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import { db } from '@/lib/db';
|
| 3 |
+
import { getCookieToken, getSessionUser } from '@/lib/auth';
|
| 4 |
+
import { chatSchema } from '@/lib/validation';
|
| 5 |
+
import { chatRateLimit } from '@/lib/rate-limit';
|
| 6 |
+
|
| 7 |
+
function buildInvoiceContext(userId: string): string {
|
| 8 |
+
// Intentionally synchronous placeholder — actual query runs async
|
| 9 |
+
return '';
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export async function POST(request: NextRequest) {
|
| 13 |
+
try {
|
| 14 |
+
// ── Auth ──────────────────────────────────────────────────────────
|
| 15 |
+
const token = getCookieToken(request);
|
| 16 |
+
if (!token) {
|
| 17 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 18 |
+
}
|
| 19 |
+
const user = await getSessionUser(token);
|
| 20 |
+
if (!user) {
|
| 21 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
// ── Plan Check (Pro / Enterprise only) ───────────────────────────
|
| 25 |
+
if (user.plan !== 'pro' && user.plan !== 'enterprise') {
|
| 26 |
+
return NextResponse.json(
|
| 27 |
+
{ error: 'AI Chat is available on Pro and Enterprise plans. Please upgrade your plan.' },
|
| 28 |
+
{ status: 403 }
|
| 29 |
+
);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// ── Rate Limit ────────────────────────────────────────────────────
|
| 33 |
+
const { allowed, retryAfterMs } = await chatRateLimit(`chat:${user.id}`);
|
| 34 |
+
if (!allowed) {
|
| 35 |
+
return NextResponse.json(
|
| 36 |
+
{ error: 'Chat rate limit exceeded. Please try again later.' },
|
| 37 |
+
{
|
| 38 |
+
status: 429,
|
| 39 |
+
headers: { 'Retry-After': String(Math.ceil(retryAfterMs / 1000)) },
|
| 40 |
+
}
|
| 41 |
+
);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// ── Validate Body ─────────────────────────────────────────────────
|
| 45 |
+
const body = await request.json();
|
| 46 |
+
const parsed = chatSchema.safeParse(body);
|
| 47 |
+
if (!parsed.success) {
|
| 48 |
+
return NextResponse.json(
|
| 49 |
+
{ error: parsed.error.issues[0].message },
|
| 50 |
+
{ status: 400 }
|
| 51 |
+
);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
const { message, history = [] } = parsed.data;
|
| 55 |
+
|
| 56 |
+
// ── Fetch User's Invoices for Context ─────────────────────────────
|
| 57 |
+
const recentInvoices = await db.invoice.findMany({
|
| 58 |
+
where: { userId: user.id },
|
| 59 |
+
orderBy: { createdAt: 'desc' },
|
| 60 |
+
take: 20,
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
const invoiceSummary = recentInvoices.length > 0
|
| 64 |
+
? recentInvoices
|
| 65 |
+
.map(
|
| 66 |
+
(inv) =>
|
| 67 |
+
`- Invoice ${inv.invNumber || 'N/A'} from ${inv.vendor || 'Unknown'}: ${inv.currency} ${inv.total ?? 0} (${inv.status}, ${inv.isDuplicate ? 'duplicate' : 'unique'}, confidence: ${inv.confidence ? (inv.confidence * 100).toFixed(0) + '%' : 'N/A'})`
|
| 68 |
+
)
|
| 69 |
+
.join('\n')
|
| 70 |
+
: 'No invoices found.';
|
| 71 |
+
|
| 72 |
+
const totalAmount = recentInvoices.reduce((sum, inv) => sum + (inv.total ?? 0), 0);
|
| 73 |
+
const duplicateCount = recentInvoices.filter((inv) => inv.isDuplicate).length;
|
| 74 |
+
|
| 75 |
+
const systemPrompt = `You are OmniParse AI, an intelligent invoice processing assistant. You help users understand, analyze, and manage their invoices. Be concise, helpful, and professional.
|
| 76 |
+
|
| 77 |
+
Here is the user's recent invoice data:
|
| 78 |
+
Total invoices (recent 20): ${recentInvoices.length}
|
| 79 |
+
Total amount: USD ${totalAmount.toFixed(2)}
|
| 80 |
+
Duplicates found: ${duplicateCount}
|
| 81 |
+
|
| 82 |
+
${invoiceSummary}
|
| 83 |
+
|
| 84 |
+
The user is on the "${user.plan}" plan. Answer questions about their invoices, provide spending insights, flag potential duplicates, and help with invoice management. Keep responses concise and actionable.`;
|
| 85 |
+
|
| 86 |
+
// ── Try AI SDK, Fall Back to Demo Response ────────────────────────
|
| 87 |
+
let aiResponse: string;
|
| 88 |
+
|
| 89 |
+
try {
|
| 90 |
+
// Attempt to use z-ai-web-dev-sdk for real AI responses
|
| 91 |
+
const { default: ZAI } = await import('z-ai-web-dev-sdk');
|
| 92 |
+
const zai = await ZAI.create();
|
| 93 |
+
|
| 94 |
+
const messages: Array<{ role: string; content: string }> = [
|
| 95 |
+
{ role: 'system', content: systemPrompt },
|
| 96 |
+
...history.map((h) => ({ role: h.role, content: h.content })),
|
| 97 |
+
{ role: 'user', content: message },
|
| 98 |
+
];
|
| 99 |
+
|
| 100 |
+
const result = await zai.chat.completions.create({
|
| 101 |
+
model: 'default',
|
| 102 |
+
messages: messages as never,
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
const completion = result as Record<string, unknown>;
|
| 106 |
+
const choices = completion.choices as Array<{ message: { content: string } }>;
|
| 107 |
+
aiResponse = choices?.[0]?.message?.content ?? 'I was unable to generate a response. Please try again.';
|
| 108 |
+
} catch {
|
| 109 |
+
// Fallback: Generate a helpful context-aware demo response
|
| 110 |
+
aiResponse = generateDemoResponse(message, recentInvoices, totalAmount, duplicateCount, user.plan);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
return NextResponse.json({ reply: aiResponse });
|
| 114 |
+
} catch (error) {
|
| 115 |
+
console.error('[CHAT_ERROR]', error);
|
| 116 |
+
return NextResponse.json(
|
| 117 |
+
{ error: 'Internal server error' },
|
| 118 |
+
{ status: 500 }
|
| 119 |
+
);
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
// ─── Demo Response Generator (fallback when AI SDK unavailable) ────────────────
|
| 124 |
+
|
| 125 |
+
function generateDemoResponse(
|
| 126 |
+
message: string,
|
| 127 |
+
invoices: Array<{
|
| 128 |
+
vendor: string | null;
|
| 129 |
+
invNumber: string | null;
|
| 130 |
+
total: number | null;
|
| 131 |
+
currency: string;
|
| 132 |
+
status: string;
|
| 133 |
+
isDuplicate: boolean;
|
| 134 |
+
confidence: number | null;
|
| 135 |
+
invDate: string | null;
|
| 136 |
+
dueDate: string | null;
|
| 137 |
+
}>,
|
| 138 |
+
totalAmount: number,
|
| 139 |
+
duplicateCount: number,
|
| 140 |
+
plan: string
|
| 141 |
+
): string {
|
| 142 |
+
const msgLower = message.toLowerCase();
|
| 143 |
+
|
| 144 |
+
// Summary / overview queries
|
| 145 |
+
if (msgLower.includes('summary') || msgLower.includes('overview') || msgLower.includes('how many')) {
|
| 146 |
+
if (invoices.length === 0) {
|
| 147 |
+
return "You don't have any invoices yet. Upload some invoices to get started, and I'll help you analyze them!";
|
| 148 |
+
}
|
| 149 |
+
const vendors = [...new Set(invoices.map((i) => i.vendor).filter(Boolean))];
|
| 150 |
+
const avgAmount = totalAmount / invoices.length;
|
| 151 |
+
return `Here's a quick overview of your invoices:\n\n` +
|
| 152 |
+
`📊 **Total invoices:** ${invoices.length}\n` +
|
| 153 |
+
`💰 **Total amount:** $${totalAmount.toFixed(2)}\n` +
|
| 154 |
+
`📈 **Average invoice:** $${avgAmount.toFixed(2)}\n` +
|
| 155 |
+
`🏢 **Unique vendors:** ${vendors.length} (${vendors.slice(0, 5).join(', ')}${vendors.length > 5 ? '...' : ''})\n` +
|
| 156 |
+
`🔄 **Duplicates:** ${duplicateCount}\n\n` +
|
| 157 |
+
`Would you like me to break this down by vendor or time period?`;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// Duplicate queries
|
| 161 |
+
if (msgLower.includes('duplicate') || msgLower.includes('repeat')) {
|
| 162 |
+
if (duplicateCount === 0) {
|
| 163 |
+
return "Great news! No duplicates were detected in your invoices. The system automatically flags potential duplicates during upload based on vendor and amount matching.";
|
| 164 |
+
}
|
| 165 |
+
const dups = invoices.filter((i) => i.isDuplicate);
|
| 166 |
+
const dupSummary = dups
|
| 167 |
+
.map((i) => `• ${i.invNumber || 'N/A'} from ${i.vendor || 'Unknown'} — $${i.total ?? 0}`)
|
| 168 |
+
.join('\n');
|
| 169 |
+
return `⚠️ **${duplicateCount} potential duplicate(s) found:**\n\n${dupSummary}\n\n` +
|
| 170 |
+
`These were flagged because they share the same vendor and a similar amount with another invoice. You can review them in the "Duplicates" tab or delete any that are truly repeated.`;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
// Vendor queries
|
| 174 |
+
if (msgLower.includes('vendor') || msgLower.includes('supplier') || msgLower.includes('company')) {
|
| 175 |
+
const vendorMap = new Map<string, { count: number; total: number }>();
|
| 176 |
+
for (const inv of invoices) {
|
| 177 |
+
const v = inv.vendor || 'Unknown';
|
| 178 |
+
const existing = vendorMap.get(v) ?? { count: 0, total: 0 };
|
| 179 |
+
vendorMap.set(v, {
|
| 180 |
+
count: existing.count + 1,
|
| 181 |
+
total: existing.total + (inv.total ?? 0),
|
| 182 |
+
});
|
| 183 |
+
}
|
| 184 |
+
const lines = [...vendorMap.entries()]
|
| 185 |
+
.sort((a, b) => b[1].total - a[1].total)
|
| 186 |
+
.map(([v, data]) => `• **${v}**: ${data.count} invoice(s), totaling $${data.total.toFixed(2)}`)
|
| 187 |
+
.join('\n');
|
| 188 |
+
return `Here's a breakdown by vendor:\n\n${lines || 'No vendor data available.'}`;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
// Spending / amount queries
|
| 192 |
+
if (msgLower.includes('spend') || msgLower.includes('cost') || msgLower.includes('total') || msgLower.includes('amount')) {
|
| 193 |
+
if (invoices.length === 0) return "No invoices to analyze yet.";
|
| 194 |
+
const avgAmount = totalAmount / invoices.length;
|
| 195 |
+
const maxInv = invoices.reduce((a, b) => (b.total ?? 0) > (a.total ?? 0) ? b : a);
|
| 196 |
+
const minInv = invoices.reduce((a, b) => (b.total ?? 0) < (a.total ?? 0) ? b : a);
|
| 197 |
+
return `💰 **Spending Analysis:**\n\n` +
|
| 198 |
+
`• Total: $${totalAmount.toFixed(2)} across ${invoices.length} invoices\n` +
|
| 199 |
+
`• Average: $${avgAmount.toFixed(2)} per invoice\n` +
|
| 200 |
+
`• Largest: $${maxInv.total ?? 0} from ${maxInv.vendor || 'Unknown'} (${maxInv.invNumber || 'N/A'})\n` +
|
| 201 |
+
`• Smallest: $${minInv.total ?? 0} from ${minInv.vendor || 'Unknown'} (${minInv.invNumber || 'N/A'})\n\n` +
|
| 202 |
+
`Want me to analyze spending trends or identify cost-saving opportunities?`;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// Default helpful response
|
| 206 |
+
return `I can help you with your invoices! Here are some things you can ask me:\n\n` +
|
| 207 |
+
`• **"Give me a summary"** — Overview of all your invoices\n` +
|
| 208 |
+
`• **"Show duplicates"** — Find potential duplicate invoices\n` +
|
| 209 |
+
`• **"Breakdown by vendor"** — Spending per vendor\n` +
|
| 210 |
+
`• **"Spending analysis"** — Cost insights and trends\n\n` +
|
| 211 |
+
`You currently have **${invoices.length} invoice(s)** totaling **$${totalAmount.toFixed(2)}** on the **${plan}** plan. How can I help?`;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
// Suppress unused import warning
|
| 215 |
+
void buildInvoiceContext;
|
src/app/api/invoices/export/csv/route.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import { db } from '@/lib/db';
|
| 3 |
+
import { getCookieToken, getSessionUser } from '@/lib/auth';
|
| 4 |
+
import { logActivity } from '@/lib/activity';
|
| 5 |
+
|
| 6 |
+
function escapeCsvField(value: unknown): string {
|
| 7 |
+
const str = value === null || value === undefined ? '' : String(value);
|
| 8 |
+
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
| 9 |
+
return `"${str.replace(/"/g, '""')}"`;
|
| 10 |
+
}
|
| 11 |
+
return str;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export async function GET(request: NextRequest) {
|
| 15 |
+
try {
|
| 16 |
+
const token = getCookieToken(request);
|
| 17 |
+
if (!token) {
|
| 18 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 19 |
+
}
|
| 20 |
+
const user = await getSessionUser(token);
|
| 21 |
+
if (!user) {
|
| 22 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const invoices = await db.invoice.findMany({
|
| 26 |
+
where: { userId: user.id },
|
| 27 |
+
orderBy: { createdAt: 'desc' },
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
const headers = [
|
| 31 |
+
'ID',
|
| 32 |
+
'Filename',
|
| 33 |
+
'Vendor',
|
| 34 |
+
'Invoice Number',
|
| 35 |
+
'Invoice Date',
|
| 36 |
+
'Due Date',
|
| 37 |
+
'Subtotal',
|
| 38 |
+
'VAT Amount',
|
| 39 |
+
'Total',
|
| 40 |
+
'Currency',
|
| 41 |
+
'Status',
|
| 42 |
+
'Duplicate',
|
| 43 |
+
'Confidence',
|
| 44 |
+
'Created At',
|
| 45 |
+
];
|
| 46 |
+
|
| 47 |
+
const rows = invoices.map((inv) =>
|
| 48 |
+
[
|
| 49 |
+
inv.id,
|
| 50 |
+
inv.filename ?? '',
|
| 51 |
+
inv.vendor ?? '',
|
| 52 |
+
inv.invNumber ?? '',
|
| 53 |
+
inv.invDate ?? '',
|
| 54 |
+
inv.dueDate ?? '',
|
| 55 |
+
inv.amount ?? '',
|
| 56 |
+
inv.vatAmount ?? '',
|
| 57 |
+
inv.total ?? '',
|
| 58 |
+
inv.currency,
|
| 59 |
+
inv.status,
|
| 60 |
+
inv.isDuplicate ? 'Yes' : 'No',
|
| 61 |
+
inv.confidence !== null ? (inv.confidence * 100).toFixed(1) + '%' : '',
|
| 62 |
+
inv.createdAt.toISOString(),
|
| 63 |
+
].map(escapeCsvField).join(',')
|
| 64 |
+
);
|
| 65 |
+
|
| 66 |
+
const csvContent = [
|
| 67 |
+
headers.map(escapeCsvField).join(','),
|
| 68 |
+
...rows,
|
| 69 |
+
].join('\n');
|
| 70 |
+
|
| 71 |
+
const timestamp = new Date().toISOString().split('T')[0];
|
| 72 |
+
|
| 73 |
+
logActivity(user.id, 'export', 'CSV exported', 'Exported all invoices as CSV');
|
| 74 |
+
|
| 75 |
+
return new NextResponse(csvContent, {
|
| 76 |
+
status: 200,
|
| 77 |
+
headers: {
|
| 78 |
+
'Content-Type': 'text/csv; charset=utf-8',
|
| 79 |
+
'Content-Disposition': `attachment; filename="invoices-${timestamp}.csv"`,
|
| 80 |
+
},
|
| 81 |
+
});
|
| 82 |
+
} catch (error) {
|
| 83 |
+
console.error('[EXPORT_CSV_ERROR]', error);
|
| 84 |
+
return NextResponse.json(
|
| 85 |
+
{ error: 'Internal server error' },
|
| 86 |
+
{ status: 500 }
|
| 87 |
+
);
|
| 88 |
+
}
|
| 89 |
+
}
|
src/app/api/invoices/export/json/route.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import { db } from '@/lib/db';
|
| 3 |
+
import { getCookieToken, getSessionUser } from '@/lib/auth';
|
| 4 |
+
|
| 5 |
+
export async function GET(request: NextRequest) {
|
| 6 |
+
try {
|
| 7 |
+
const token = getCookieToken(request);
|
| 8 |
+
if (!token) {
|
| 9 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 10 |
+
}
|
| 11 |
+
const user = await getSessionUser(token);
|
| 12 |
+
if (!user) {
|
| 13 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
// Free plan users cannot export JSON
|
| 17 |
+
if (user.plan === 'free') {
|
| 18 |
+
return NextResponse.json(
|
| 19 |
+
{ error: 'JSON export is available on Basic, Pro, and Enterprise plans. Please upgrade your plan.' },
|
| 20 |
+
{ status: 403 }
|
| 21 |
+
);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const invoices = await db.invoice.findMany({
|
| 25 |
+
where: { userId: user.id },
|
| 26 |
+
orderBy: { createdAt: 'desc' },
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
const jsonData = invoices.map((inv) => ({
|
| 30 |
+
id: inv.id,
|
| 31 |
+
filename: inv.filename,
|
| 32 |
+
vendor: inv.vendor,
|
| 33 |
+
invoiceNumber: inv.invNumber,
|
| 34 |
+
invoiceDate: inv.invDate,
|
| 35 |
+
dueDate: inv.dueDate,
|
| 36 |
+
subtotal: inv.amount,
|
| 37 |
+
vatAmount: inv.vatAmount,
|
| 38 |
+
total: inv.total,
|
| 39 |
+
currency: inv.currency,
|
| 40 |
+
status: inv.status,
|
| 41 |
+
isDuplicate: inv.isDuplicate,
|
| 42 |
+
confidence: inv.confidence,
|
| 43 |
+
rawJson: inv.rawJson ? JSON.parse(inv.rawJson) : null,
|
| 44 |
+
createdAt: inv.createdAt,
|
| 45 |
+
updatedAt: inv.updatedAt,
|
| 46 |
+
}));
|
| 47 |
+
|
| 48 |
+
const timestamp = new Date().toISOString().split('T')[0];
|
| 49 |
+
return new NextResponse(JSON.stringify(jsonData, null, 2), {
|
| 50 |
+
status: 200,
|
| 51 |
+
headers: {
|
| 52 |
+
'Content-Type': 'application/json; charset=utf-8',
|
| 53 |
+
'Content-Disposition': `attachment; filename="invoices-${timestamp}.json"`,
|
| 54 |
+
},
|
| 55 |
+
});
|
| 56 |
+
} catch (error) {
|
| 57 |
+
console.error('[EXPORT_JSON_ERROR]', error);
|
| 58 |
+
return NextResponse.json(
|
| 59 |
+
{ error: 'Internal server error' },
|
| 60 |
+
{ status: 500 }
|
| 61 |
+
);
|
| 62 |
+
}
|
| 63 |
+
}
|
src/app/api/invoices/export/xlsx/route.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import { db } from '@/lib/db';
|
| 3 |
+
import { getCookieToken, getSessionUser } from '@/lib/auth';
|
| 4 |
+
|
| 5 |
+
export async function GET(request: NextRequest) {
|
| 6 |
+
try {
|
| 7 |
+
const token = getCookieToken(request);
|
| 8 |
+
if (!token) {
|
| 9 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 10 |
+
}
|
| 11 |
+
const user = await getSessionUser(token);
|
| 12 |
+
if (!user) {
|
| 13 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const invoices = await db.invoice.findMany({
|
| 17 |
+
where: { userId: user.id },
|
| 18 |
+
orderBy: { createdAt: 'desc' },
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
// Dynamic import to avoid bundling issues
|
| 22 |
+
const ExcelJS = await import('exceljs');
|
| 23 |
+
const workbook = new ExcelJS.Workbook();
|
| 24 |
+
const worksheet = workbook.addWorksheet('Invoices');
|
| 25 |
+
|
| 26 |
+
// Style definitions
|
| 27 |
+
const headerFill = {
|
| 28 |
+
type: 'pattern' as const,
|
| 29 |
+
pattern: 'solid' as const,
|
| 30 |
+
fgColor: { argb: 'FFF59E0B' },
|
| 31 |
+
};
|
| 32 |
+
const headerFont = { bold: true, color: { argb: 'FF09090B' }, size: 11 };
|
| 33 |
+
const borderStyle = {
|
| 34 |
+
bottom: { style: 'thin' as const, color: { argb: 'FFD4D4D8' } },
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
// Title row
|
| 38 |
+
worksheet.mergeCells('A1:K1');
|
| 39 |
+
const titleCell = worksheet.getCell('A1');
|
| 40 |
+
titleCell.value = 'OmniParse AI \u2014 Invoice Export';
|
| 41 |
+
titleCell.font = { bold: true, size: 14, color: { argb: 'FF18181B' } };
|
| 42 |
+
titleCell.alignment = { horizontal: 'center' };
|
| 43 |
+
|
| 44 |
+
// Date row
|
| 45 |
+
worksheet.mergeCells('A2:K2');
|
| 46 |
+
const dateCell = worksheet.getCell('A2');
|
| 47 |
+
dateCell.value = `Exported: ${new Date().toLocaleString('en-US')}`;
|
| 48 |
+
dateCell.font = { italic: true, size: 10, color: { argb: 'FF71717A' } };
|
| 49 |
+
dateCell.alignment = { horizontal: 'center' };
|
| 50 |
+
|
| 51 |
+
// Headers (row 4)
|
| 52 |
+
const headers = ['Vendor', 'Invoice #', 'Invoice Date', 'Due Date', 'Amount', 'VAT', 'Total', 'Currency', 'Status', 'Duplicate', 'Confidence', 'Filename'];
|
| 53 |
+
headers.forEach((h, i) => {
|
| 54 |
+
const cell = worksheet.getCell(4, i + 1);
|
| 55 |
+
cell.value = h;
|
| 56 |
+
cell.fill = headerFill;
|
| 57 |
+
cell.font = headerFont;
|
| 58 |
+
cell.alignment = { horizontal: 'center' };
|
| 59 |
+
cell.border = borderStyle;
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
// Data rows
|
| 63 |
+
invoices.forEach((inv, idx) => {
|
| 64 |
+
const row = idx + 5;
|
| 65 |
+
worksheet.getCell(row, 1).value = inv.vendor || '';
|
| 66 |
+
worksheet.getCell(row, 2).value = inv.invNumber || '';
|
| 67 |
+
worksheet.getCell(row, 3).value = inv.invDate || '';
|
| 68 |
+
worksheet.getCell(row, 4).value = inv.dueDate || '';
|
| 69 |
+
worksheet.getCell(row, 5).value = inv.amount ?? 0;
|
| 70 |
+
worksheet.getCell(row, 6).value = inv.vatAmount ?? 0;
|
| 71 |
+
worksheet.getCell(row, 7).value = inv.total ?? 0;
|
| 72 |
+
worksheet.getCell(row, 8).value = inv.currency;
|
| 73 |
+
worksheet.getCell(row, 9).value = inv.isDuplicate ? 'Yes' : 'No';
|
| 74 |
+
worksheet.getCell(row, 10).value = inv.confidence ? `${(inv.confidence * 100).toFixed(1)}%` : '';
|
| 75 |
+
worksheet.getCell(row, 11).value = inv.filename || '';
|
| 76 |
+
|
| 77 |
+
// Format currency columns
|
| 78 |
+
[5, 6, 7].forEach(col => {
|
| 79 |
+
worksheet.getCell(row, col).numFmt = '$#,##0.00';
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
// Alternate row shading
|
| 83 |
+
if (idx % 2 === 0) {
|
| 84 |
+
for (let col = 1; col <= 12; col++) {
|
| 85 |
+
worksheet.getCell(row, col).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF4F4F5' } };
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// Color status
|
| 90 |
+
const statusCell = worksheet.getCell(row, 9);
|
| 91 |
+
if (inv.isDuplicate) {
|
| 92 |
+
statusCell.font = { color: { argb: 'FFEF4444' } };
|
| 93 |
+
} else if (inv.status === 'review') {
|
| 94 |
+
statusCell.font = { color: { argb: 'FFEAB308' } };
|
| 95 |
+
} else {
|
| 96 |
+
statusCell.font = { color: { argb: 'FF22C55E' } };
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// Bottom border
|
| 100 |
+
for (let col = 1; col <= 12; col++) {
|
| 101 |
+
worksheet.getCell(row, col).border = borderStyle;
|
| 102 |
+
}
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
// Summary row
|
| 106 |
+
const summaryRow = invoices.length + 5;
|
| 107 |
+
worksheet.getCell(summaryRow, 6).value = 'TOTAL';
|
| 108 |
+
worksheet.getCell(summaryRow, 6).font = { bold: true };
|
| 109 |
+
const totalAmount = invoices.reduce((s, i) => s + (i.total ?? 0), 0);
|
| 110 |
+
worksheet.getCell(summaryRow, 7).value = totalAmount;
|
| 111 |
+
worksheet.getCell(summaryRow, 7).numFmt = '$#,##0.00';
|
| 112 |
+
worksheet.getCell(summaryRow, 7).font = { bold: true, size: 12 };
|
| 113 |
+
for (let col = 1; col <= 12; col++) {
|
| 114 |
+
worksheet.getCell(summaryRow, col).border = { top: { style: 'double', color: { argb: 'FF18181B' } } };
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// Auto-fit column widths
|
| 118 |
+
worksheet.columns.forEach((column, i) => {
|
| 119 |
+
const maxLength = Math.max(
|
| 120 |
+
headers[i]?.length || 5,
|
| 121 |
+
...invoices.slice(0, 20).map(inv => {
|
| 122 |
+
const vals = [inv.vendor, inv.invNumber, inv.invDate, inv.dueDate, inv.currency, inv.filename];
|
| 123 |
+
return (vals[i] || '').toString().length;
|
| 124 |
+
})
|
| 125 |
+
);
|
| 126 |
+
column.width = Math.min(maxLength + 4, 30);
|
| 127 |
+
});
|
| 128 |
+
|
| 129 |
+
const buffer = await workbook.xlsx.writeBuffer();
|
| 130 |
+
return new NextResponse(buffer, {
|
| 131 |
+
status: 200,
|
| 132 |
+
headers: {
|
| 133 |
+
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
| 134 |
+
'Content-Disposition': `attachment; filename="omniparse-invoices-${new Date().toISOString().split('T')[0]}.xlsx"`,
|
| 135 |
+
},
|
| 136 |
+
});
|
| 137 |
+
} catch (error) {
|
| 138 |
+
console.error('[EXPORT_XLSX_ERROR]', error);
|
| 139 |
+
return NextResponse.json(
|
| 140 |
+
{ error: 'Internal server error' },
|
| 141 |
+
{ status: 500 }
|
| 142 |
+
);
|
| 143 |
+
}
|
| 144 |
+
}
|
src/app/api/invoices/route.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import { db } from '@/lib/db';
|
| 3 |
+
import { getCookieToken, getSessionUser } from '@/lib/auth';
|
| 4 |
+
import { invoiceIdSchema } from '@/lib/validation';
|
| 5 |
+
import { logActivity } from '@/lib/activity';
|
| 6 |
+
|
| 7 |
+
type FilterValue = 'all' | 'done' | 'review' | 'duplicates';
|
| 8 |
+
|
| 9 |
+
// ─── GET: List Invoices ───────────────────────────────────────────────────────
|
| 10 |
+
|
| 11 |
+
export async function GET(request: NextRequest) {
|
| 12 |
+
try {
|
| 13 |
+
const token = getCookieToken(request);
|
| 14 |
+
if (!token) {
|
| 15 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 16 |
+
}
|
| 17 |
+
const user = await getSessionUser(token);
|
| 18 |
+
if (!user) {
|
| 19 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const { searchParams } = new URL(request.url);
|
| 23 |
+
const filter = (searchParams.get('filter') || 'all') as FilterValue;
|
| 24 |
+
|
| 25 |
+
// Build the where clause
|
| 26 |
+
const where: Record<string, unknown> = { userId: user.id };
|
| 27 |
+
|
| 28 |
+
switch (filter) {
|
| 29 |
+
case 'done':
|
| 30 |
+
where.status = 'done';
|
| 31 |
+
where.isDuplicate = false;
|
| 32 |
+
break;
|
| 33 |
+
case 'review':
|
| 34 |
+
where.status = 'review';
|
| 35 |
+
break;
|
| 36 |
+
case 'duplicates':
|
| 37 |
+
where.isDuplicate = true;
|
| 38 |
+
break;
|
| 39 |
+
// 'all' uses the base where (userId only)
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const invoices = await db.invoice.findMany({
|
| 43 |
+
where,
|
| 44 |
+
orderBy: { createdAt: 'desc' },
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
return NextResponse.json({ invoices });
|
| 48 |
+
} catch (error) {
|
| 49 |
+
console.error('[INVOICES_GET_ERROR]', error);
|
| 50 |
+
return NextResponse.json(
|
| 51 |
+
{ error: 'Internal server error' },
|
| 52 |
+
{ status: 500 }
|
| 53 |
+
);
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// ─── DELETE: Remove Invoice ────────────────────────────────────────────────────
|
| 58 |
+
|
| 59 |
+
export async function DELETE(request: NextRequest) {
|
| 60 |
+
try {
|
| 61 |
+
const token = getCookieToken(request);
|
| 62 |
+
if (!token) {
|
| 63 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 64 |
+
}
|
| 65 |
+
const user = await getSessionUser(token);
|
| 66 |
+
if (!user) {
|
| 67 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
const body = await request.json();
|
| 71 |
+
const parsed = invoiceIdSchema.safeParse(body);
|
| 72 |
+
if (!parsed.success) {
|
| 73 |
+
return NextResponse.json(
|
| 74 |
+
{ error: parsed.error.issues[0].message },
|
| 75 |
+
{ status: 400 }
|
| 76 |
+
);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
// Verify ownership
|
| 80 |
+
const invoice = await db.invoice.findUnique({
|
| 81 |
+
where: { id: parsed.data.id },
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
if (!invoice || invoice.userId !== user.id) {
|
| 85 |
+
return NextResponse.json(
|
| 86 |
+
{ error: 'Invoice not found' },
|
| 87 |
+
{ status: 404 }
|
| 88 |
+
);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
await db.invoice.delete({ where: { id: parsed.data.id } });
|
| 92 |
+
|
| 93 |
+
logActivity(user.id, 'delete', 'Invoice deleted', `Removed invoice ${parsed.data.id}`);
|
| 94 |
+
|
| 95 |
+
return NextResponse.json({ success: true });
|
| 96 |
+
} catch (error) {
|
| 97 |
+
console.error('[INVOICES_DELETE_ERROR]', error);
|
| 98 |
+
return NextResponse.json(
|
| 99 |
+
{ error: 'Internal server error' },
|
| 100 |
+
{ status: 500 }
|
| 101 |
+
);
|
| 102 |
+
}
|
| 103 |
+
}
|
src/app/api/invoices/update/route.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import { db } from '@/lib/db';
|
| 3 |
+
import { getSessionUser } from '@/lib/auth';
|
| 4 |
+
import { z } from 'zod';
|
| 5 |
+
import { logActivity } from '@/lib/activity';
|
| 6 |
+
|
| 7 |
+
const updateSchema = z.object({
|
| 8 |
+
id: z.string().min(1),
|
| 9 |
+
vendor: z.string().optional(),
|
| 10 |
+
invNumber: z.string().optional(),
|
| 11 |
+
invDate: z.string().optional(),
|
| 12 |
+
dueDate: z.string().optional(),
|
| 13 |
+
amount: z.string().transform(Number).optional(),
|
| 14 |
+
vatAmount: z.string().transform(Number).optional(),
|
| 15 |
+
total: z.string().transform(Number).optional(),
|
| 16 |
+
currency: z.string().optional(),
|
| 17 |
+
status: z.enum(['done', 'review']).optional(),
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
export async function POST(req: NextRequest) {
|
| 21 |
+
try {
|
| 22 |
+
const user = await getSessionUser(req);
|
| 23 |
+
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
| 24 |
+
|
| 25 |
+
const body = await req.json();
|
| 26 |
+
const parsed = updateSchema.safeParse(body);
|
| 27 |
+
if (!parsed.success) return NextResponse.json({ error: 'Invalid input' }, { status: 400 });
|
| 28 |
+
|
| 29 |
+
const { id, ...data } = parsed.data;
|
| 30 |
+
const invoice = await db.invoice.findFirst({ where: { id, userId: user.id } });
|
| 31 |
+
if (!invoice) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
| 32 |
+
|
| 33 |
+
const updated = await db.invoice.update({
|
| 34 |
+
where: { id },
|
| 35 |
+
data,
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
logActivity(user.id, 'edit', 'Invoice updated', 'Updated invoice fields');
|
| 39 |
+
|
| 40 |
+
return NextResponse.json({ invoice: updated });
|
| 41 |
+
} catch (error) {
|
| 42 |
+
console.error('Invoice update error:', error);
|
| 43 |
+
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
|
| 44 |
+
}
|
| 45 |
+
}
|
src/app/api/route.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from "next/server";
|
| 2 |
+
|
| 3 |
+
export async function GET() {
|
| 4 |
+
return NextResponse.json({ message: "Hello, world!" });
|
| 5 |
+
}
|
src/app/api/seed/route.ts
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { db } from '@/lib/db';
|
| 2 |
+
import { hashPassword, generateApiKey } from '@/lib/auth';
|
| 3 |
+
import { NextResponse } from 'next/server';
|
| 4 |
+
|
| 5 |
+
export async function GET() {
|
| 6 |
+
try {
|
| 7 |
+
// Upsert demo user
|
| 8 |
+
const hashedPw = await hashPassword('demo1234');
|
| 9 |
+
const apiKey = generateApiKey();
|
| 10 |
+
|
| 11 |
+
const user = await db.user.upsert({
|
| 12 |
+
where: { email: 'demo@omniparse.ai' },
|
| 13 |
+
update: {},
|
| 14 |
+
create: {
|
| 15 |
+
email: 'demo@omniparse.ai',
|
| 16 |
+
name: 'Demo User',
|
| 17 |
+
password: hashedPw,
|
| 18 |
+
plan: 'pro',
|
| 19 |
+
apiKey,
|
| 20 |
+
},
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
// Delete any existing invoices for this user
|
| 24 |
+
await db.invoice.deleteMany({
|
| 25 |
+
where: { userId: user.id },
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
// Define 20 diverse invoices
|
| 29 |
+
const invoiceData = [
|
| 30 |
+
// ── January 2025 (5 invoices) ──
|
| 31 |
+
{
|
| 32 |
+
vendor: 'Acme Corp',
|
| 33 |
+
invNumber: 'INV-2024-001',
|
| 34 |
+
invDate: '2025-01-05',
|
| 35 |
+
dueDate: '2025-02-04',
|
| 36 |
+
amount: 1200,
|
| 37 |
+
vatPct: 0.20,
|
| 38 |
+
currency: 'USD',
|
| 39 |
+
status: 'done' as const,
|
| 40 |
+
isDuplicate: false,
|
| 41 |
+
confidence: 0.95,
|
| 42 |
+
filename: 'invoice_acme_jan.pdf',
|
| 43 |
+
createdAt: new Date('2025-01-06T09:15:00Z'),
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
vendor: 'TechVision Inc',
|
| 47 |
+
invNumber: 'INV-2024-002',
|
| 48 |
+
invDate: '2025-01-12',
|
| 49 |
+
dueDate: '2025-02-11',
|
| 50 |
+
amount: 3450,
|
| 51 |
+
vatPct: 0.21,
|
| 52 |
+
currency: 'EUR',
|
| 53 |
+
status: 'done' as const,
|
| 54 |
+
isDuplicate: false,
|
| 55 |
+
confidence: 0.97,
|
| 56 |
+
filename: 'invoice_techvision_jan.pdf',
|
| 57 |
+
createdAt: new Date('2025-01-13T14:30:00Z'),
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
vendor: 'Global Supplies Ltd',
|
| 61 |
+
invNumber: 'INV-2024-003',
|
| 62 |
+
invDate: '2025-01-18',
|
| 63 |
+
dueDate: '2025-02-17',
|
| 64 |
+
amount: 890,
|
| 65 |
+
vatPct: 0.15,
|
| 66 |
+
currency: 'USD',
|
| 67 |
+
status: 'done' as const,
|
| 68 |
+
isDuplicate: false,
|
| 69 |
+
confidence: 0.92,
|
| 70 |
+
filename: 'receipt_global_jan.png',
|
| 71 |
+
createdAt: new Date('2025-01-19T11:00:00Z'),
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
vendor: 'CloudNine Services',
|
| 75 |
+
invNumber: 'INV-2024-004',
|
| 76 |
+
invDate: '2025-01-22',
|
| 77 |
+
dueDate: '2025-02-21',
|
| 78 |
+
amount: 5200,
|
| 79 |
+
vatPct: 0.20,
|
| 80 |
+
currency: 'USD',
|
| 81 |
+
status: 'review' as const,
|
| 82 |
+
isDuplicate: false,
|
| 83 |
+
confidence: 0.88,
|
| 84 |
+
filename: 'invoice_cloudnine_jan.pdf',
|
| 85 |
+
createdAt: new Date('2025-01-23T08:45:00Z'),
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
vendor: 'DataFlow Systems',
|
| 89 |
+
invNumber: 'INV-2024-005',
|
| 90 |
+
invDate: '2025-01-28',
|
| 91 |
+
dueDate: '2025-02-27',
|
| 92 |
+
amount: 750,
|
| 93 |
+
vatPct: 0.10,
|
| 94 |
+
currency: 'USD',
|
| 95 |
+
status: 'done' as const,
|
| 96 |
+
isDuplicate: false,
|
| 97 |
+
confidence: 0.96,
|
| 98 |
+
filename: 'receipt_dataflow_jan.pdf',
|
| 99 |
+
createdAt: new Date('2025-01-29T16:20:00Z'),
|
| 100 |
+
},
|
| 101 |
+
|
| 102 |
+
// ── February 2025 (5 invoices) ──
|
| 103 |
+
{
|
| 104 |
+
vendor: 'Pinnacle Solutions',
|
| 105 |
+
invNumber: 'INV-2024-006',
|
| 106 |
+
invDate: '2025-02-03',
|
| 107 |
+
dueDate: '2025-03-05',
|
| 108 |
+
amount: 1250,
|
| 109 |
+
vatPct: 0.18,
|
| 110 |
+
currency: 'USD',
|
| 111 |
+
status: 'done' as const,
|
| 112 |
+
isDuplicate: false,
|
| 113 |
+
confidence: 0.94,
|
| 114 |
+
filename: 'invoice_pinnacle_feb.pdf',
|
| 115 |
+
createdAt: new Date('2025-02-04T10:00:00Z'),
|
| 116 |
+
},
|
| 117 |
+
{
|
| 118 |
+
vendor: 'TechVision Inc',
|
| 119 |
+
invNumber: 'INV-2024-007',
|
| 120 |
+
invDate: '2025-02-08',
|
| 121 |
+
dueDate: '2025-03-10',
|
| 122 |
+
amount: 2100,
|
| 123 |
+
vatPct: 0.20,
|
| 124 |
+
currency: 'USD',
|
| 125 |
+
status: 'done' as const,
|
| 126 |
+
isDuplicate: false,
|
| 127 |
+
confidence: 0.98,
|
| 128 |
+
filename: 'invoice_techvision_feb.pdf',
|
| 129 |
+
createdAt: new Date('2025-02-09T13:15:00Z'),
|
| 130 |
+
},
|
| 131 |
+
{
|
| 132 |
+
vendor: 'Acme Corp',
|
| 133 |
+
invNumber: 'INV-2024-008',
|
| 134 |
+
invDate: '2025-02-14',
|
| 135 |
+
dueDate: '2025-03-16',
|
| 136 |
+
amount: 8750,
|
| 137 |
+
vatPct: 0.20,
|
| 138 |
+
currency: 'GBP',
|
| 139 |
+
status: 'review' as const,
|
| 140 |
+
isDuplicate: false,
|
| 141 |
+
confidence: 0.87,
|
| 142 |
+
filename: 'invoice_acme_feb.pdf',
|
| 143 |
+
createdAt: new Date('2025-02-15T09:30:00Z'),
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
vendor: 'Meridian Group',
|
| 147 |
+
invNumber: 'INV-2024-009',
|
| 148 |
+
invDate: '2025-02-20',
|
| 149 |
+
dueDate: '2025-03-22',
|
| 150 |
+
amount: 430,
|
| 151 |
+
vatPct: 0.12,
|
| 152 |
+
currency: 'USD',
|
| 153 |
+
status: 'pending' as const,
|
| 154 |
+
isDuplicate: false,
|
| 155 |
+
confidence: 0.99,
|
| 156 |
+
filename: 'receipt_meridian_feb.png',
|
| 157 |
+
createdAt: new Date('2025-02-21T15:45:00Z'),
|
| 158 |
+
},
|
| 159 |
+
{
|
| 160 |
+
vendor: 'Nexus Technologies',
|
| 161 |
+
invNumber: 'INV-2024-010',
|
| 162 |
+
invDate: '2025-02-26',
|
| 163 |
+
dueDate: '2025-03-28',
|
| 164 |
+
amount: 6300,
|
| 165 |
+
vatPct: 0.25,
|
| 166 |
+
currency: 'USD',
|
| 167 |
+
status: 'done' as const,
|
| 168 |
+
isDuplicate: false,
|
| 169 |
+
confidence: 0.91,
|
| 170 |
+
filename: 'invoice_nexus_feb.pdf',
|
| 171 |
+
createdAt: new Date('2025-02-27T11:10:00Z'),
|
| 172 |
+
},
|
| 173 |
+
|
| 174 |
+
// ── March 2025 (5 invoices) ──
|
| 175 |
+
{
|
| 176 |
+
vendor: 'CloudNine Services',
|
| 177 |
+
invNumber: 'INV-2024-011',
|
| 178 |
+
invDate: '2025-03-04',
|
| 179 |
+
dueDate: '2025-04-03',
|
| 180 |
+
amount: 4800,
|
| 181 |
+
vatPct: 0.20,
|
| 182 |
+
currency: 'USD',
|
| 183 |
+
status: 'done' as const,
|
| 184 |
+
isDuplicate: false,
|
| 185 |
+
confidence: 0.93,
|
| 186 |
+
filename: 'invoice_cloudnine_mar.pdf',
|
| 187 |
+
createdAt: new Date('2025-03-05T08:00:00Z'),
|
| 188 |
+
},
|
| 189 |
+
{
|
| 190 |
+
vendor: 'Pinnacle Solutions',
|
| 191 |
+
invNumber: 'INV-2024-012',
|
| 192 |
+
invDate: '2025-03-10',
|
| 193 |
+
dueDate: '2025-04-09',
|
| 194 |
+
amount: 15000,
|
| 195 |
+
vatPct: 0.22,
|
| 196 |
+
currency: 'USD',
|
| 197 |
+
status: 'review' as const,
|
| 198 |
+
isDuplicate: false,
|
| 199 |
+
confidence: 0.86,
|
| 200 |
+
filename: 'invoice_pinnacle_mar.pdf',
|
| 201 |
+
createdAt: new Date('2025-03-11T14:25:00Z'),
|
| 202 |
+
},
|
| 203 |
+
{
|
| 204 |
+
vendor: 'Global Supplies Ltd',
|
| 205 |
+
invNumber: 'INV-2024-013',
|
| 206 |
+
invDate: '2025-03-15',
|
| 207 |
+
dueDate: '2025-04-14',
|
| 208 |
+
amount: 2350,
|
| 209 |
+
vatPct: 0.19,
|
| 210 |
+
currency: 'EUR',
|
| 211 |
+
status: 'done' as const,
|
| 212 |
+
isDuplicate: false,
|
| 213 |
+
confidence: 0.95,
|
| 214 |
+
filename: 'invoice_global_mar.pdf',
|
| 215 |
+
createdAt: new Date('2025-03-16T10:40:00Z'),
|
| 216 |
+
},
|
| 217 |
+
{
|
| 218 |
+
vendor: 'DataFlow Systems',
|
| 219 |
+
invNumber: 'INV-2024-014',
|
| 220 |
+
invDate: '2025-03-19',
|
| 221 |
+
dueDate: '2025-04-18',
|
| 222 |
+
amount: 1100,
|
| 223 |
+
vatPct: 0.15,
|
| 224 |
+
currency: 'USD',
|
| 225 |
+
status: 'done' as const,
|
| 226 |
+
isDuplicate: false,
|
| 227 |
+
confidence: 0.97,
|
| 228 |
+
filename: 'receipt_dataflow_mar.png',
|
| 229 |
+
createdAt: new Date('2025-03-20T09:55:00Z'),
|
| 230 |
+
},
|
| 231 |
+
{
|
| 232 |
+
vendor: 'Acme Corp',
|
| 233 |
+
invNumber: 'INV-2024-015',
|
| 234 |
+
invDate: '2025-03-25',
|
| 235 |
+
dueDate: '2025-04-24',
|
| 236 |
+
amount: 9200,
|
| 237 |
+
vatPct: 0.20,
|
| 238 |
+
currency: 'USD',
|
| 239 |
+
status: 'done' as const,
|
| 240 |
+
isDuplicate: false,
|
| 241 |
+
confidence: 0.89,
|
| 242 |
+
filename: 'invoice_acme_mar.pdf',
|
| 243 |
+
createdAt: new Date('2025-03-26T16:30:00Z'),
|
| 244 |
+
},
|
| 245 |
+
|
| 246 |
+
// ── April 2025 (5 invoices) ──
|
| 247 |
+
{
|
| 248 |
+
vendor: 'Nexus Technologies',
|
| 249 |
+
invNumber: 'INV-2024-016',
|
| 250 |
+
invDate: '2025-04-02',
|
| 251 |
+
dueDate: '2025-05-02',
|
| 252 |
+
amount: 6280,
|
| 253 |
+
vatPct: 0.23,
|
| 254 |
+
currency: 'USD',
|
| 255 |
+
status: 'done' as const,
|
| 256 |
+
isDuplicate: true, // Duplicate of INV-2024-010 (same vendor, $6300)
|
| 257 |
+
confidence: 0.88,
|
| 258 |
+
filename: 'invoice_nexus_apr.pdf',
|
| 259 |
+
createdAt: new Date('2025-04-03T11:20:00Z'),
|
| 260 |
+
},
|
| 261 |
+
{
|
| 262 |
+
vendor: 'Meridian Group',
|
| 263 |
+
invNumber: 'INV-2024-017',
|
| 264 |
+
invDate: '2025-04-08',
|
| 265 |
+
dueDate: '2025-05-08',
|
| 266 |
+
amount: 780,
|
| 267 |
+
vatPct: 0.14,
|
| 268 |
+
currency: 'EUR',
|
| 269 |
+
status: 'pending' as const,
|
| 270 |
+
isDuplicate: false,
|
| 271 |
+
confidence: 0.96,
|
| 272 |
+
filename: 'receipt_meridian_apr.png',
|
| 273 |
+
createdAt: new Date('2025-04-09T08:10:00Z'),
|
| 274 |
+
},
|
| 275 |
+
{
|
| 276 |
+
vendor: 'CloudNine Services',
|
| 277 |
+
invNumber: 'INV-2024-018',
|
| 278 |
+
invDate: '2025-04-14',
|
| 279 |
+
dueDate: '2025-05-14',
|
| 280 |
+
amount: 5150,
|
| 281 |
+
vatPct: 0.20,
|
| 282 |
+
currency: 'USD',
|
| 283 |
+
status: 'done' as const,
|
| 284 |
+
isDuplicate: true, // Duplicate of INV-2024-004 (same vendor, $5200)
|
| 285 |
+
confidence: 0.92,
|
| 286 |
+
filename: 'invoice_cloudnine_apr.pdf',
|
| 287 |
+
createdAt: new Date('2025-04-15T13:50:00Z'),
|
| 288 |
+
},
|
| 289 |
+
{
|
| 290 |
+
vendor: 'Pinnacle Solutions',
|
| 291 |
+
invNumber: 'INV-2024-019',
|
| 292 |
+
invDate: '2025-04-20',
|
| 293 |
+
dueDate: '2025-05-20',
|
| 294 |
+
amount: 3200,
|
| 295 |
+
vatPct: 0.20,
|
| 296 |
+
currency: 'GBP',
|
| 297 |
+
status: 'done' as const,
|
| 298 |
+
isDuplicate: false,
|
| 299 |
+
confidence: 0.94,
|
| 300 |
+
filename: 'invoice_pinnacle_apr.pdf',
|
| 301 |
+
createdAt: new Date('2025-04-21T10:05:00Z'),
|
| 302 |
+
},
|
| 303 |
+
{
|
| 304 |
+
vendor: 'TechVision Inc',
|
| 305 |
+
invNumber: 'INV-2024-020',
|
| 306 |
+
invDate: '2025-04-27',
|
| 307 |
+
dueDate: '2025-05-27',
|
| 308 |
+
amount: 2800,
|
| 309 |
+
vatPct: 0.17,
|
| 310 |
+
currency: 'USD',
|
| 311 |
+
status: 'review' as const,
|
| 312 |
+
isDuplicate: false,
|
| 313 |
+
confidence: 0.85,
|
| 314 |
+
filename: 'invoice_techvision_apr.pdf',
|
| 315 |
+
createdAt: new Date('2025-04-28T15:00:00Z'),
|
| 316 |
+
},
|
| 317 |
+
];
|
| 318 |
+
|
| 319 |
+
// Create all invoices with computed VAT and total
|
| 320 |
+
const invoices = await db.invoice.createMany({
|
| 321 |
+
data: invoiceData.map((inv) => ({
|
| 322 |
+
userId: user.id,
|
| 323 |
+
vendor: inv.vendor,
|
| 324 |
+
invNumber: inv.invNumber,
|
| 325 |
+
invDate: inv.invDate,
|
| 326 |
+
dueDate: inv.dueDate,
|
| 327 |
+
amount: inv.amount,
|
| 328 |
+
vatAmount: Math.round(inv.amount * inv.vatPct * 100) / 100,
|
| 329 |
+
total: Math.round(inv.amount * (1 + inv.vatPct) * 100) / 100,
|
| 330 |
+
currency: inv.currency,
|
| 331 |
+
status: inv.status,
|
| 332 |
+
isDuplicate: inv.isDuplicate,
|
| 333 |
+
confidence: inv.confidence,
|
| 334 |
+
filename: inv.filename,
|
| 335 |
+
rawJson: null,
|
| 336 |
+
createdAt: inv.createdAt,
|
| 337 |
+
})),
|
| 338 |
+
});
|
| 339 |
+
|
| 340 |
+
return NextResponse.json({
|
| 341 |
+
message: `Seed complete: ${invoices.count} invoices created for demo user`,
|
| 342 |
+
userId: user.id,
|
| 343 |
+
invoiceCount: invoices.count,
|
| 344 |
+
breakdown: {
|
| 345 |
+
byVendor: {
|
| 346 |
+
'Acme Corp': 3,
|
| 347 |
+
'TechVision Inc': 3,
|
| 348 |
+
'Global Supplies Ltd': 2,
|
| 349 |
+
'CloudNine Services': 3,
|
| 350 |
+
'DataFlow Systems': 2,
|
| 351 |
+
'Pinnacle Solutions': 3,
|
| 352 |
+
'Meridian Group': 2,
|
| 353 |
+
'Nexus Technologies': 2,
|
| 354 |
+
},
|
| 355 |
+
byStatus: { done: 14, review: 4, pending: 2 },
|
| 356 |
+
duplicates: 2,
|
| 357 |
+
byCurrency: { USD: 15, EUR: 3, GBP: 2 },
|
| 358 |
+
},
|
| 359 |
+
});
|
| 360 |
+
} catch (error) {
|
| 361 |
+
console.error('[SEED_ERROR]', error);
|
| 362 |
+
return NextResponse.json({ error: 'Seed failed' }, { status: 500 });
|
| 363 |
+
}
|
| 364 |
+
}
|
src/app/globals.css
ADDED
|
@@ -0,0 +1,2040 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "tailwindcss";
|
| 2 |
+
@import "tw-animate-css";
|
| 3 |
+
|
| 4 |
+
@custom-variant dark (&:is(.dark *));
|
| 5 |
+
|
| 6 |
+
@theme inline {
|
| 7 |
+
--color-background: var(--background);
|
| 8 |
+
--color-foreground: var(--foreground);
|
| 9 |
+
--font-sans: var(--font-geist-sans);
|
| 10 |
+
--font-mono: var(--font-geist-mono);
|
| 11 |
+
--color-ring: var(--ring);
|
| 12 |
+
--color-input: var(--input);
|
| 13 |
+
--color-border: var(--border);
|
| 14 |
+
--color-destructive: var(--destructive);
|
| 15 |
+
--color-accent-foreground: var(--accent-foreground);
|
| 16 |
+
--color-accent: var(--accent);
|
| 17 |
+
--color-muted-foreground: var(--muted-foreground);
|
| 18 |
+
--color-muted: var(--muted);
|
| 19 |
+
--color-secondary-foreground: var(--secondary-foreground);
|
| 20 |
+
--color-secondary: var(--secondary);
|
| 21 |
+
--color-primary-foreground: var(--primary-foreground);
|
| 22 |
+
--color-primary: var(--primary);
|
| 23 |
+
--color-popover-foreground: var(--popover-foreground);
|
| 24 |
+
--color-popover: var(--popover);
|
| 25 |
+
--color-card-foreground: var(--card-foreground);
|
| 26 |
+
--color-card: var(--card);
|
| 27 |
+
--radius-sm: calc(var(--radius) - 4px);
|
| 28 |
+
--radius-md: calc(var(--radius) - 2px);
|
| 29 |
+
--radius-lg: var(--radius);
|
| 30 |
+
--radius-xl: calc(var(--radius) + 4px);
|
| 31 |
+
--color-amber-50: #FFFBEB;
|
| 32 |
+
--color-amber-100: #FEF3C7;
|
| 33 |
+
--color-amber-200: #FDE68A;
|
| 34 |
+
--color-amber-300: #FCD34D;
|
| 35 |
+
--color-amber-400: #FBBF24;
|
| 36 |
+
--color-amber-500: #F59E0B;
|
| 37 |
+
--color-amber-600: #D97706;
|
| 38 |
+
--color-amber-700: #B45309;
|
| 39 |
+
--color-amber-800: #92400E;
|
| 40 |
+
--color-amber-900: #78350F;
|
| 41 |
+
--color-teal-400: #2DD4BF;
|
| 42 |
+
--color-teal-500: #14B8A6;
|
| 43 |
+
--color-teal-600: #0D9488;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
:root {
|
| 47 |
+
--radius: 0.625rem;
|
| 48 |
+
--background: #09090B;
|
| 49 |
+
--foreground: #FAFAFA;
|
| 50 |
+
--card: #111113;
|
| 51 |
+
--card-foreground: #FAFAFA;
|
| 52 |
+
--popover: #111113;
|
| 53 |
+
--popover-foreground: #FAFAFA;
|
| 54 |
+
--primary: #F59E0B;
|
| 55 |
+
--primary-foreground: #09090B;
|
| 56 |
+
--secondary: #1C1C1F;
|
| 57 |
+
--secondary-foreground: #FAFAFA;
|
| 58 |
+
--muted: #1C1C1F;
|
| 59 |
+
--muted-foreground: #A1A1AA;
|
| 60 |
+
--accent: #1C1C1F;
|
| 61 |
+
--accent-foreground: #FAFAFA;
|
| 62 |
+
--destructive: #EF4444;
|
| 63 |
+
--border: rgba(255, 255, 255, 0.08);
|
| 64 |
+
--input: rgba(255, 255, 255, 0.1);
|
| 65 |
+
--ring: #F59E0B;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
@layer base {
|
| 69 |
+
* {
|
| 70 |
+
@apply border-border outline-ring/50;
|
| 71 |
+
}
|
| 72 |
+
body {
|
| 73 |
+
@apply bg-background text-foreground;
|
| 74 |
+
font-feature-settings: "rlig" 1, "calt" 1;
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/* Custom scrollbar */
|
| 79 |
+
::-webkit-scrollbar {
|
| 80 |
+
width: 6px;
|
| 81 |
+
height: 6px;
|
| 82 |
+
}
|
| 83 |
+
::-webkit-scrollbar-track {
|
| 84 |
+
background: transparent;
|
| 85 |
+
}
|
| 86 |
+
::-webkit-scrollbar-thumb {
|
| 87 |
+
background: rgba(255, 255, 255, 0.1);
|
| 88 |
+
border-radius: 3px;
|
| 89 |
+
}
|
| 90 |
+
::-webkit-scrollbar-thumb:hover {
|
| 91 |
+
background: rgba(255, 255, 255, 0.2);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
/* Glassmorphism card */
|
| 95 |
+
.glass-card {
|
| 96 |
+
background: rgba(255, 255, 255, 0.03);
|
| 97 |
+
backdrop-filter: blur(12px);
|
| 98 |
+
border: 1px solid rgba(255, 255, 255, 0.06);
|
| 99 |
+
border-radius: 16px;
|
| 100 |
+
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.glass-card-hover {
|
| 104 |
+
background: rgba(255, 255, 255, 0.03);
|
| 105 |
+
backdrop-filter: blur(12px);
|
| 106 |
+
border: 1px solid rgba(255, 255, 255, 0.06);
|
| 107 |
+
border-radius: 16px;
|
| 108 |
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 109 |
+
}
|
| 110 |
+
.glass-card-hover:hover {
|
| 111 |
+
background: rgba(255, 255, 255, 0.06);
|
| 112 |
+
border-color: rgba(245, 158, 11, 0.2);
|
| 113 |
+
transform: translateY(-2px);
|
| 114 |
+
box-shadow: 0 8px 32px rgba(245, 158, 11, 0.08);
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
/* Amber glow effect */
|
| 118 |
+
.amber-glow {
|
| 119 |
+
box-shadow: 0 0 40px rgba(245, 158, 11, 0.15), 0 0 80px rgba(245, 158, 11, 0.05);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
/* Subtle grid background */
|
| 123 |
+
.grid-bg {
|
| 124 |
+
background-image:
|
| 125 |
+
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
|
| 126 |
+
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px);
|
| 127 |
+
background-size: 64px 64px;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
/* Noise texture overlay */
|
| 131 |
+
.noise-overlay::before {
|
| 132 |
+
content: "";
|
| 133 |
+
position: fixed;
|
| 134 |
+
top: 0;
|
| 135 |
+
left: 0;
|
| 136 |
+
width: 100%;
|
| 137 |
+
height: 100%;
|
| 138 |
+
opacity: 0.03;
|
| 139 |
+
z-index: 0;
|
| 140 |
+
pointer-events: none;
|
| 141 |
+
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
/* Upload drop zone */
|
| 145 |
+
.drop-zone {
|
| 146 |
+
border: 2px dashed rgba(245, 158, 11, 0.3);
|
| 147 |
+
border-radius: 16px;
|
| 148 |
+
transition: all 0.3s ease;
|
| 149 |
+
}
|
| 150 |
+
.drop-zone.drag-over {
|
| 151 |
+
border-color: #F59E0B;
|
| 152 |
+
background: rgba(245, 158, 11, 0.05);
|
| 153 |
+
box-shadow: 0 0 32px rgba(245, 158, 11, 0.1);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
/* Chat bubble animation */
|
| 157 |
+
@keyframes slideUp {
|
| 158 |
+
from { opacity: 0; transform: translateY(8px); }
|
| 159 |
+
to { opacity: 1; transform: translateY(0); }
|
| 160 |
+
}
|
| 161 |
+
.chat-bubble {
|
| 162 |
+
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
/* Pricing card highlight */
|
| 166 |
+
.pricing-highlight {
|
| 167 |
+
border: 1px solid rgba(245, 158, 11, 0.4);
|
| 168 |
+
background: linear-gradient(135deg, rgba(245, 158, 11, 0.08) 0%, rgba(20, 184, 166, 0.04) 100%);
|
| 169 |
+
position: relative;
|
| 170 |
+
}
|
| 171 |
+
.pricing-highlight::before {
|
| 172 |
+
content: "";
|
| 173 |
+
position: absolute;
|
| 174 |
+
inset: -1px;
|
| 175 |
+
border-radius: 16px;
|
| 176 |
+
padding: 1px;
|
| 177 |
+
background: linear-gradient(135deg, #F59E0B, #14B8A6);
|
| 178 |
+
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
| 179 |
+
-webkit-mask-composite: xor;
|
| 180 |
+
mask-composite: exclude;
|
| 181 |
+
pointer-events: none;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
/* Fade in animation */
|
| 185 |
+
@keyframes fadeIn {
|
| 186 |
+
from { opacity: 0; }
|
| 187 |
+
to { opacity: 1; }
|
| 188 |
+
}
|
| 189 |
+
.fade-in {
|
| 190 |
+
animation: fadeIn 0.5s ease;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
/* Status badge styles */
|
| 194 |
+
.status-done { background: rgba(34, 197, 94, 0.12); color: #22C55E; }
|
| 195 |
+
.status-review { background: rgba(234, 179, 8, 0.12); color: #EAB308; }
|
| 196 |
+
.status-duplicate { background: rgba(239, 68, 68, 0.12); color: #EF4444; }
|
| 197 |
+
|
| 198 |
+
/* Tab active indicator */
|
| 199 |
+
.tab-active {
|
| 200 |
+
color: #F59E0B;
|
| 201 |
+
border-bottom: 2px solid #F59E0B;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
/* Focus ring for accessibility */
|
| 205 |
+
*:focus-visible {
|
| 206 |
+
outline: 2px solid #F59E0B;
|
| 207 |
+
outline-offset: 2px;
|
| 208 |
+
border-radius: 4px;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
/* Hero gradient text */
|
| 212 |
+
.gradient-text {
|
| 213 |
+
background: linear-gradient(135deg, #F59E0B 0%, #FBBF24 40%, #14B8A6 100%);
|
| 214 |
+
-webkit-background-clip: text;
|
| 215 |
+
-webkit-text-fill-color: transparent;
|
| 216 |
+
background-clip: text;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
/* Progress bar */
|
| 220 |
+
.progress-bar {
|
| 221 |
+
background: linear-gradient(90deg, #F59E0B, #14B8A6);
|
| 222 |
+
border-radius: 4px;
|
| 223 |
+
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
/* Skeleton shimmer */
|
| 227 |
+
@keyframes shimmer {
|
| 228 |
+
0% { background-position: -200% 0; }
|
| 229 |
+
100% { background-position: 200% 0; }
|
| 230 |
+
}
|
| 231 |
+
.skeleton-shimmer {
|
| 232 |
+
background: linear-gradient(90deg, rgba(255,255,255,0.03) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.03) 75%);
|
| 233 |
+
background-size: 200% 100%;
|
| 234 |
+
animation: shimmer 1.5s infinite;
|
| 235 |
+
border-radius: 8px;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
/* Hero gradient orb */
|
| 239 |
+
.hero-orb {
|
| 240 |
+
position: absolute;
|
| 241 |
+
width: 600px;
|
| 242 |
+
height: 600px;
|
| 243 |
+
border-radius: 50%;
|
| 244 |
+
background: radial-gradient(circle, rgba(245,158,11,0.12) 0%, rgba(20,184,166,0.06) 40%, transparent 70%);
|
| 245 |
+
filter: blur(60px);
|
| 246 |
+
top: -100px;
|
| 247 |
+
right: -150px;
|
| 248 |
+
pointer-events: none;
|
| 249 |
+
animation: orbFloat 8s ease-in-out infinite;
|
| 250 |
+
}
|
| 251 |
+
.hero-orb-2 {
|
| 252 |
+
position: absolute;
|
| 253 |
+
width: 400px;
|
| 254 |
+
height: 400px;
|
| 255 |
+
border-radius: 50%;
|
| 256 |
+
background: radial-gradient(circle, rgba(20,184,166,0.1) 0%, rgba(245,158,11,0.05) 40%, transparent 70%);
|
| 257 |
+
filter: blur(50px);
|
| 258 |
+
bottom: -80px;
|
| 259 |
+
left: -100px;
|
| 260 |
+
pointer-events: none;
|
| 261 |
+
animation: orbFloat 10s ease-in-out infinite reverse;
|
| 262 |
+
}
|
| 263 |
+
@keyframes orbFloat {
|
| 264 |
+
0%, 100% { transform: translate(0, 0) scale(1); }
|
| 265 |
+
33% { transform: translate(30px, -20px) scale(1.05); }
|
| 266 |
+
66% { transform: translate(-20px, 15px) scale(0.95); }
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
/* Stats counter animation */
|
| 270 |
+
@keyframes countUp {
|
| 271 |
+
from { opacity: 0; transform: translateY(10px); }
|
| 272 |
+
to { opacity: 1; transform: translateY(0); }
|
| 273 |
+
}
|
| 274 |
+
.stat-item {
|
| 275 |
+
animation: countUp 0.6s ease-out both;
|
| 276 |
+
}
|
| 277 |
+
.stat-item:nth-child(2) { animation-delay: 0.1s; }
|
| 278 |
+
.stat-item:nth-child(3) { animation-delay: 0.2s; }
|
| 279 |
+
|
| 280 |
+
/* Feature card icon colors */
|
| 281 |
+
.feature-amber .feature-icon { background: rgba(245,158,11,0.1); color: #F59E0B; }
|
| 282 |
+
.feature-teal .feature-icon { background: rgba(20,184,166,0.1); color: #14B8A6; }
|
| 283 |
+
.feature-red .feature-icon { background: rgba(239,68,68,0.1); color: #EF4444; }
|
| 284 |
+
.feature-green .feature-icon { background: rgba(34,197,94,0.1); color: #22C55E; }
|
| 285 |
+
.feature-sky .feature-icon { background: rgba(56,189,248,0.1); color: #38BDF8; }
|
| 286 |
+
.feature-rose .feature-icon { background: rgba(244,114,182,0.1); color: #F472B6; }
|
| 287 |
+
|
| 288 |
+
/* Dashboard stat card */
|
| 289 |
+
.stat-card {
|
| 290 |
+
background: rgba(255,255,255,0.03);
|
| 291 |
+
border: 1px solid rgba(255,255,255,0.06);
|
| 292 |
+
border-radius: 12px;
|
| 293 |
+
padding: 16px 20px;
|
| 294 |
+
transition: all 0.3s ease;
|
| 295 |
+
}
|
| 296 |
+
.stat-card:hover {
|
| 297 |
+
background: rgba(255,255,255,0.05);
|
| 298 |
+
border-color: rgba(255,255,255,0.1);
|
| 299 |
+
transform: translateY(-1px);
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
/* Invoice row hover */
|
| 303 |
+
.invoice-row {
|
| 304 |
+
cursor: pointer;
|
| 305 |
+
transition: background 0.15s ease;
|
| 306 |
+
}
|
| 307 |
+
.invoice-row:hover {
|
| 308 |
+
background: rgba(255,255,255,0.03) !important;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
/* Section reveal animation */
|
| 312 |
+
@keyframes sectionReveal {
|
| 313 |
+
from { opacity: 0; transform: translateY(24px); }
|
| 314 |
+
to { opacity: 1; transform: translateY(0); }
|
| 315 |
+
}
|
| 316 |
+
.section-reveal {
|
| 317 |
+
animation: sectionReveal 0.7s ease-out both;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
/* Footer link hover */
|
| 321 |
+
.footer-link {
|
| 322 |
+
color: #A1A1AA;
|
| 323 |
+
transition: color 0.2s ease;
|
| 324 |
+
cursor: pointer;
|
| 325 |
+
}
|
| 326 |
+
.footer-link:hover {
|
| 327 |
+
color: #F59E0B;
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
/* Typing dots animation */
|
| 331 |
+
@keyframes typingDot {
|
| 332 |
+
0%, 60%, 100% { opacity: 0.3; }
|
| 333 |
+
30% { opacity: 1; }
|
| 334 |
+
}
|
| 335 |
+
.typing-dot {
|
| 336 |
+
animation: typingDot 1.4s ease-in-out infinite;
|
| 337 |
+
}
|
| 338 |
+
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
|
| 339 |
+
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
|
| 340 |
+
|
| 341 |
+
/* ========================================
|
| 342 |
+
Enhanced Button Styles
|
| 343 |
+
======================================== */
|
| 344 |
+
|
| 345 |
+
/* Primary button with gradient */
|
| 346 |
+
.btn-primary {
|
| 347 |
+
background: linear-gradient(135deg, #F59E0B 0%, #D97706 100%);
|
| 348 |
+
color: #09090B;
|
| 349 |
+
font-weight: 600;
|
| 350 |
+
padding: 10px 24px;
|
| 351 |
+
border-radius: 10px;
|
| 352 |
+
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
| 353 |
+
position: relative;
|
| 354 |
+
overflow: hidden;
|
| 355 |
+
}
|
| 356 |
+
.btn-primary:hover {
|
| 357 |
+
transform: translateY(-1px);
|
| 358 |
+
box-shadow: 0 4px 20px rgba(245, 158, 11, 0.35);
|
| 359 |
+
}
|
| 360 |
+
.btn-primary:active {
|
| 361 |
+
transform: translateY(0);
|
| 362 |
+
}
|
| 363 |
+
.btn-primary:disabled {
|
| 364 |
+
opacity: 0.5;
|
| 365 |
+
cursor: not-allowed;
|
| 366 |
+
transform: none;
|
| 367 |
+
box-shadow: none;
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
/* Secondary/ghost button */
|
| 371 |
+
.btn-ghost {
|
| 372 |
+
background: transparent;
|
| 373 |
+
color: #FAFAFA;
|
| 374 |
+
font-weight: 500;
|
| 375 |
+
padding: 10px 24px;
|
| 376 |
+
border-radius: 10px;
|
| 377 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 378 |
+
transition: all 0.25s ease;
|
| 379 |
+
}
|
| 380 |
+
.btn-ghost:hover {
|
| 381 |
+
background: rgba(255, 255, 255, 0.05);
|
| 382 |
+
border-color: rgba(255, 255, 255, 0.2);
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
/* ========================================
|
| 386 |
+
Form Input Enhancements
|
| 387 |
+
======================================== */
|
| 388 |
+
|
| 389 |
+
/* Custom form inputs */
|
| 390 |
+
.input-field {
|
| 391 |
+
background: rgba(255, 255, 255, 0.04);
|
| 392 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 393 |
+
border-radius: 10px;
|
| 394 |
+
padding: 12px 16px;
|
| 395 |
+
color: #FAFAFA;
|
| 396 |
+
font-size: 14px;
|
| 397 |
+
transition: all 0.2s ease;
|
| 398 |
+
width: 100%;
|
| 399 |
+
}
|
| 400 |
+
.input-field:focus {
|
| 401 |
+
outline: none;
|
| 402 |
+
border-color: #F59E0B;
|
| 403 |
+
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.1);
|
| 404 |
+
background: rgba(255, 255, 255, 0.06);
|
| 405 |
+
}
|
| 406 |
+
.input-field::placeholder {
|
| 407 |
+
color: #52525B;
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
/* Input with floating label */
|
| 411 |
+
.input-group {
|
| 412 |
+
position: relative;
|
| 413 |
+
}
|
| 414 |
+
.input-label {
|
| 415 |
+
display: block;
|
| 416 |
+
font-size: 13px;
|
| 417 |
+
font-weight: 500;
|
| 418 |
+
color: #A1A1AA;
|
| 419 |
+
margin-bottom: 6px;
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
/* ========================================
|
| 423 |
+
Enhanced Card Styles
|
| 424 |
+
======================================== */
|
| 425 |
+
|
| 426 |
+
/* Card with colored top border accent */
|
| 427 |
+
.card-accent-amber { border-top: 2px solid rgba(245, 158, 11, 0.6); }
|
| 428 |
+
.card-accent-teal { border-top: 2px solid rgba(20, 184, 166, 0.6); }
|
| 429 |
+
.card-accent-red { border-top: 2px solid rgba(239, 68, 68, 0.6); }
|
| 430 |
+
.card-accent-green { border-top: 2px solid rgba(34, 197, 94, 0.6); }
|
| 431 |
+
|
| 432 |
+
/* Hover lift effect */
|
| 433 |
+
.hover-lift {
|
| 434 |
+
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease;
|
| 435 |
+
}
|
| 436 |
+
.hover-lift:hover {
|
| 437 |
+
transform: translateY(-4px);
|
| 438 |
+
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
/* ========================================
|
| 442 |
+
Badge/Tag Styles
|
| 443 |
+
======================================== */
|
| 444 |
+
|
| 445 |
+
/* Plan badge */
|
| 446 |
+
.badge-amber {
|
| 447 |
+
background: rgba(245, 158, 11, 0.12);
|
| 448 |
+
color: #F59E0B;
|
| 449 |
+
padding: 2px 10px;
|
| 450 |
+
border-radius: 100px;
|
| 451 |
+
font-size: 12px;
|
| 452 |
+
font-weight: 600;
|
| 453 |
+
letter-spacing: 0.02em;
|
| 454 |
+
}
|
| 455 |
+
.badge-teal {
|
| 456 |
+
background: rgba(20, 184, 166, 0.12);
|
| 457 |
+
color: #14B8A6;
|
| 458 |
+
padding: 2px 10px;
|
| 459 |
+
border-radius: 100px;
|
| 460 |
+
font-size: 12px;
|
| 461 |
+
font-weight: 600;
|
| 462 |
+
letter-spacing: 0.02em;
|
| 463 |
+
}
|
| 464 |
+
.badge-muted {
|
| 465 |
+
background: rgba(255, 255, 255, 0.06);
|
| 466 |
+
color: #A1A1AA;
|
| 467 |
+
padding: 2px 10px;
|
| 468 |
+
border-radius: 100px;
|
| 469 |
+
font-size: 12px;
|
| 470 |
+
font-weight: 500;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
/* ========================================
|
| 474 |
+
Tab Navigation Enhancement
|
| 475 |
+
======================================== */
|
| 476 |
+
|
| 477 |
+
/* Dashboard tab styles */
|
| 478 |
+
.dash-tab {
|
| 479 |
+
padding: 10px 16px;
|
| 480 |
+
font-size: 14px;
|
| 481 |
+
font-weight: 500;
|
| 482 |
+
color: #71717A;
|
| 483 |
+
border-bottom: 2px solid transparent;
|
| 484 |
+
transition: all 0.2s ease;
|
| 485 |
+
cursor: pointer;
|
| 486 |
+
white-space: nowrap;
|
| 487 |
+
}
|
| 488 |
+
.dash-tab:hover {
|
| 489 |
+
color: #FAFAFA;
|
| 490 |
+
background: rgba(255, 255, 255, 0.03);
|
| 491 |
+
}
|
| 492 |
+
.dash-tab.active {
|
| 493 |
+
color: #F59E0B;
|
| 494 |
+
border-bottom-color: #F59E0B;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
/* ========================================
|
| 498 |
+
Tooltip Enhancement
|
| 499 |
+
======================================== */
|
| 500 |
+
|
| 501 |
+
/* Custom tooltip */
|
| 502 |
+
.tooltip-wrapper {
|
| 503 |
+
position: relative;
|
| 504 |
+
}
|
| 505 |
+
.tooltip-wrapper .tooltip-text {
|
| 506 |
+
visibility: hidden;
|
| 507 |
+
opacity: 0;
|
| 508 |
+
position: absolute;
|
| 509 |
+
bottom: calc(100% + 8px);
|
| 510 |
+
left: 50%;
|
| 511 |
+
transform: translateX(-50%) translateY(4px);
|
| 512 |
+
background: #27272A;
|
| 513 |
+
color: #FAFAFA;
|
| 514 |
+
padding: 6px 12px;
|
| 515 |
+
border-radius: 8px;
|
| 516 |
+
font-size: 12px;
|
| 517 |
+
white-space: nowrap;
|
| 518 |
+
z-index: 50;
|
| 519 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 520 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
| 521 |
+
transition: all 0.2s ease;
|
| 522 |
+
pointer-events: none;
|
| 523 |
+
}
|
| 524 |
+
.tooltip-wrapper:hover .tooltip-text {
|
| 525 |
+
visibility: visible;
|
| 526 |
+
opacity: 1;
|
| 527 |
+
transform: translateX(-50%) translateY(0);
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
/* ========================================
|
| 531 |
+
Animation Enhancements
|
| 532 |
+
======================================== */
|
| 533 |
+
|
| 534 |
+
/* Pulse glow for CTA elements */
|
| 535 |
+
@keyframes pulseGlow {
|
| 536 |
+
0%, 100% { box-shadow: 0 0 20px rgba(245, 158, 11, 0.2); }
|
| 537 |
+
50% { box-shadow: 0 0 40px rgba(245, 158, 11, 0.4); }
|
| 538 |
+
}
|
| 539 |
+
.pulse-glow {
|
| 540 |
+
animation: pulseGlow 3s ease-in-out infinite;
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
/* Slide in from right */
|
| 544 |
+
@keyframes slideInRight {
|
| 545 |
+
from { opacity: 0; transform: translateX(20px); }
|
| 546 |
+
to { opacity: 1; transform: translateX(0); }
|
| 547 |
+
}
|
| 548 |
+
.slide-in-right {
|
| 549 |
+
animation: slideInRight 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
/* Scale in */
|
| 553 |
+
@keyframes scaleIn {
|
| 554 |
+
from { opacity: 0; transform: scale(0.95); }
|
| 555 |
+
to { opacity: 1; transform: scale(1); }
|
| 556 |
+
}
|
| 557 |
+
.scale-in {
|
| 558 |
+
animation: scaleIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
/* Stagger children */
|
| 562 |
+
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
|
| 563 |
+
.stagger-children > *:nth-child(2) { animation-delay: 80ms; }
|
| 564 |
+
.stagger-children > *:nth-child(3) { animation-delay: 160ms; }
|
| 565 |
+
.stagger-children > *:nth-child(4) { animation-delay: 240ms; }
|
| 566 |
+
.stagger-children > *:nth-child(5) { animation-delay: 320ms; }
|
| 567 |
+
.stagger-children > *:nth-child(6) { animation-delay: 400ms; }
|
| 568 |
+
.stagger-children > * {
|
| 569 |
+
opacity: 0;
|
| 570 |
+
animation: fadeIn 0.5s ease forwards;
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
/* ========================================
|
| 574 |
+
Table Enhancements
|
| 575 |
+
======================================== */
|
| 576 |
+
|
| 577 |
+
/* Modern data table */
|
| 578 |
+
.data-table {
|
| 579 |
+
width: 100%;
|
| 580 |
+
border-collapse: separate;
|
| 581 |
+
border-spacing: 0;
|
| 582 |
+
}
|
| 583 |
+
.data-table thead th {
|
| 584 |
+
padding: 12px 16px;
|
| 585 |
+
text-align: left;
|
| 586 |
+
font-size: 12px;
|
| 587 |
+
font-weight: 600;
|
| 588 |
+
text-transform: uppercase;
|
| 589 |
+
letter-spacing: 0.05em;
|
| 590 |
+
color: #71717A;
|
| 591 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
| 592 |
+
position: sticky;
|
| 593 |
+
top: 0;
|
| 594 |
+
background: #111113;
|
| 595 |
+
z-index: 1;
|
| 596 |
+
}
|
| 597 |
+
.data-table tbody td {
|
| 598 |
+
padding: 12px 16px;
|
| 599 |
+
font-size: 14px;
|
| 600 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
| 601 |
+
color: #D4D4D8;
|
| 602 |
+
}
|
| 603 |
+
.data-table tbody tr:last-child td {
|
| 604 |
+
border-bottom: none;
|
| 605 |
+
}
|
| 606 |
+
|
| 607 |
+
/* ========================================
|
| 608 |
+
Code Block Style (for API docs)
|
| 609 |
+
======================================== */
|
| 610 |
+
|
| 611 |
+
.code-block {
|
| 612 |
+
background: #0C0C0E;
|
| 613 |
+
border: 1px solid rgba(255, 255, 255, 0.06);
|
| 614 |
+
border-radius: 10px;
|
| 615 |
+
padding: 16px 20px;
|
| 616 |
+
font-family: 'Geist Mono', monospace;
|
| 617 |
+
font-size: 13px;
|
| 618 |
+
line-height: 1.6;
|
| 619 |
+
color: #A1A1AA;
|
| 620 |
+
overflow-x: auto;
|
| 621 |
+
}
|
| 622 |
+
.code-block .code-keyword { color: #F59E0B; }
|
| 623 |
+
.code-block .code-string { color: #14B8A6; }
|
| 624 |
+
.code-block .code-comment { color: #52525B; }
|
| 625 |
+
|
| 626 |
+
/* ========================================
|
| 627 |
+
Scroll-to-top button
|
| 628 |
+
======================================== */
|
| 629 |
+
|
| 630 |
+
.scroll-top-btn {
|
| 631 |
+
position: fixed;
|
| 632 |
+
bottom: 24px;
|
| 633 |
+
right: 24px;
|
| 634 |
+
width: 44px;
|
| 635 |
+
height: 44px;
|
| 636 |
+
border-radius: 12px;
|
| 637 |
+
background: rgba(245, 158, 11, 0.15);
|
| 638 |
+
border: 1px solid rgba(245, 158, 11, 0.3);
|
| 639 |
+
color: #F59E0B;
|
| 640 |
+
display: flex;
|
| 641 |
+
align-items: center;
|
| 642 |
+
justify-content: center;
|
| 643 |
+
cursor: pointer;
|
| 644 |
+
opacity: 0;
|
| 645 |
+
transform: translateY(8px);
|
| 646 |
+
transition: all 0.3s ease;
|
| 647 |
+
z-index: 40;
|
| 648 |
+
}
|
| 649 |
+
.scroll-top-btn.visible {
|
| 650 |
+
opacity: 1;
|
| 651 |
+
transform: translateY(0);
|
| 652 |
+
}
|
| 653 |
+
.scroll-top-btn:hover {
|
| 654 |
+
background: rgba(245, 158, 11, 0.25);
|
| 655 |
+
transform: translateY(-2px);
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
/* ========================================
|
| 659 |
+
Empty State
|
| 660 |
+
======================================== */
|
| 661 |
+
|
| 662 |
+
.empty-state {
|
| 663 |
+
display: flex;
|
| 664 |
+
flex-direction: column;
|
| 665 |
+
align-items: center;
|
| 666 |
+
justify-content: center;
|
| 667 |
+
padding: 48px 24px;
|
| 668 |
+
color: #52525B;
|
| 669 |
+
}
|
| 670 |
+
.empty-state svg {
|
| 671 |
+
width: 48px;
|
| 672 |
+
height: 48px;
|
| 673 |
+
margin-bottom: 16px;
|
| 674 |
+
opacity: 0.4;
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
/* ========================================
|
| 678 |
+
Navbar blur
|
| 679 |
+
======================================== */
|
| 680 |
+
|
| 681 |
+
.nav-blur {
|
| 682 |
+
backdrop-filter: blur(16px) saturate(180%);
|
| 683 |
+
background: rgba(9, 9, 11, 0.8);
|
| 684 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
/* ========================================
|
| 688 |
+
Detail panel slide-over
|
| 689 |
+
======================================== */
|
| 690 |
+
|
| 691 |
+
.detail-panel {
|
| 692 |
+
animation: slideInRight 0.3s ease;
|
| 693 |
+
border-left: 1px solid rgba(255, 255, 255, 0.06);
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
/* ========================================
|
| 697 |
+
Notification badge
|
| 698 |
+
======================================== */
|
| 699 |
+
|
| 700 |
+
.notification-badge {
|
| 701 |
+
position: absolute;
|
| 702 |
+
top: -2px;
|
| 703 |
+
right: -2px;
|
| 704 |
+
width: 8px;
|
| 705 |
+
height: 8px;
|
| 706 |
+
background: #EF4444;
|
| 707 |
+
border-radius: 50%;
|
| 708 |
+
border: 2px solid #09090B;
|
| 709 |
+
}
|
| 710 |
+
|
| 711 |
+
/* ========================================
|
| 712 |
+
Animated Drop Zone
|
| 713 |
+
======================================== */
|
| 714 |
+
|
| 715 |
+
@keyframes dashRotate {
|
| 716 |
+
to { stroke-dashoffset: -20; }
|
| 717 |
+
}
|
| 718 |
+
.drop-zone-animated {
|
| 719 |
+
border: 2px dashed rgba(245, 158, 11, 0.3);
|
| 720 |
+
border-radius: 16px;
|
| 721 |
+
transition: all 0.3s ease;
|
| 722 |
+
background-image: url("data:image/svg+xml,%3Csvg width='40' height='40' viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg stroke='%23F59E0B' stroke-opacity='0.04' stroke-width='1'%3E%3Cpath d='M0 20h40M20 0v40'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
|
| 723 |
+
}
|
| 724 |
+
.drop-zone-animated:hover {
|
| 725 |
+
border-color: rgba(245, 158, 11, 0.5);
|
| 726 |
+
background: rgba(245, 158, 11, 0.03);
|
| 727 |
+
}
|
| 728 |
+
|
| 729 |
+
/* ========================================
|
| 730 |
+
Notification Dropdown
|
| 731 |
+
======================================== */
|
| 732 |
+
|
| 733 |
+
.notification-dropdown {
|
| 734 |
+
position: absolute;
|
| 735 |
+
top: calc(100% + 8px);
|
| 736 |
+
right: 0;
|
| 737 |
+
width: 320px;
|
| 738 |
+
background: #18181B;
|
| 739 |
+
border: 1px solid rgba(255,255,255,0.08);
|
| 740 |
+
border-radius: 12px;
|
| 741 |
+
box-shadow: 0 16px 48px rgba(0,0,0,0.5);
|
| 742 |
+
z-index: 100;
|
| 743 |
+
overflow: hidden;
|
| 744 |
+
}
|
| 745 |
+
.notification-item {
|
| 746 |
+
padding: 12px 16px;
|
| 747 |
+
border-bottom: 1px solid rgba(255,255,255,0.04);
|
| 748 |
+
transition: background 0.15s ease;
|
| 749 |
+
}
|
| 750 |
+
.notification-item:hover {
|
| 751 |
+
background: rgba(255,255,255,0.03);
|
| 752 |
+
}
|
| 753 |
+
.notification-item:last-child {
|
| 754 |
+
border-bottom: none;
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
/* ========================================
|
| 758 |
+
Modal / Dialog Overlay
|
| 759 |
+
======================================== */
|
| 760 |
+
|
| 761 |
+
.modal-overlay {
|
| 762 |
+
position: fixed;
|
| 763 |
+
inset: 0;
|
| 764 |
+
background: rgba(0,0,0,0.6);
|
| 765 |
+
backdrop-filter: blur(4px);
|
| 766 |
+
z-index: 100;
|
| 767 |
+
display: flex;
|
| 768 |
+
align-items: center;
|
| 769 |
+
justify-content: center;
|
| 770 |
+
animation: fadeIn 0.2s ease;
|
| 771 |
+
}
|
| 772 |
+
.modal-content {
|
| 773 |
+
background: #18181B;
|
| 774 |
+
border: 1px solid rgba(255,255,255,0.08);
|
| 775 |
+
border-radius: 16px;
|
| 776 |
+
padding: 24px;
|
| 777 |
+
max-width: 480px;
|
| 778 |
+
width: 90%;
|
| 779 |
+
animation: scaleIn 0.2s ease;
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
+
/* ========================================
|
| 783 |
+
Enhanced Chat Message Styles
|
| 784 |
+
======================================== */
|
| 785 |
+
|
| 786 |
+
.chat-message-user {
|
| 787 |
+
background: linear-gradient(135deg, #F59E0B 0%, #D97706 100%);
|
| 788 |
+
color: #09090B;
|
| 789 |
+
border-radius: 20px 20px 6px 20px;
|
| 790 |
+
padding: 12px 16px;
|
| 791 |
+
max-width: 80%;
|
| 792 |
+
}
|
| 793 |
+
.chat-message-assistant {
|
| 794 |
+
background: rgba(255,255,255,0.06);
|
| 795 |
+
color: #FAFAFA;
|
| 796 |
+
border-radius: 20px 20px 20px 6px;
|
| 797 |
+
padding: 12px 16px;
|
| 798 |
+
max-width: 80%;
|
| 799 |
+
border: 1px solid rgba(255,255,255,0.06);
|
| 800 |
+
}
|
| 801 |
+
.chat-timestamp {
|
| 802 |
+
font-size: 11px;
|
| 803 |
+
color: rgba(255,255,255,0.3);
|
| 804 |
+
margin-top: 4px;
|
| 805 |
+
}
|
| 806 |
+
|
| 807 |
+
/* ========================================
|
| 808 |
+
Button Ripple / Pulse Effect
|
| 809 |
+
======================================== */
|
| 810 |
+
|
| 811 |
+
.btn-primary::after {
|
| 812 |
+
content: '';
|
| 813 |
+
position: absolute;
|
| 814 |
+
inset: 0;
|
| 815 |
+
border-radius: inherit;
|
| 816 |
+
opacity: 0;
|
| 817 |
+
transition: opacity 0.3s ease;
|
| 818 |
+
background: radial-gradient(circle at center, rgba(255,255,255,0.2) 0%, transparent 70%);
|
| 819 |
+
}
|
| 820 |
+
.btn-primary:active::after {
|
| 821 |
+
opacity: 1;
|
| 822 |
+
transition: opacity 0s;
|
| 823 |
+
}
|
| 824 |
+
|
| 825 |
+
/* ========================================
|
| 826 |
+
Keyboard Shortcut Badge
|
| 827 |
+
======================================== */
|
| 828 |
+
|
| 829 |
+
.kbd {
|
| 830 |
+
display: inline-flex;
|
| 831 |
+
align-items: center;
|
| 832 |
+
justify-content: center;
|
| 833 |
+
padding: 2px 6px;
|
| 834 |
+
font-size: 11px;
|
| 835 |
+
font-family: 'Geist Mono', monospace;
|
| 836 |
+
color: #A1A1AA;
|
| 837 |
+
background: rgba(255,255,255,0.06);
|
| 838 |
+
border: 1px solid rgba(255,255,255,0.1);
|
| 839 |
+
border-radius: 6px;
|
| 840 |
+
box-shadow: 0 1px 0 rgba(255,255,255,0.05);
|
| 841 |
+
line-height: 1.4;
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
/* ========================================
|
| 845 |
+
Checkbox Styling (Batch Operations)
|
| 846 |
+
======================================== */
|
| 847 |
+
|
| 848 |
+
.invoice-checkbox {
|
| 849 |
+
appearance: none;
|
| 850 |
+
width: 16px;
|
| 851 |
+
height: 16px;
|
| 852 |
+
border: 1.5px solid rgba(255,255,255,0.2);
|
| 853 |
+
border-radius: 4px;
|
| 854 |
+
background: transparent;
|
| 855 |
+
cursor: pointer;
|
| 856 |
+
transition: all 0.15s ease;
|
| 857 |
+
position: relative;
|
| 858 |
+
}
|
| 859 |
+
.invoice-checkbox:checked {
|
| 860 |
+
background: #F59E0B;
|
| 861 |
+
border-color: #F59E0B;
|
| 862 |
+
}
|
| 863 |
+
.invoice-checkbox:checked::after {
|
| 864 |
+
content: '';
|
| 865 |
+
position: absolute;
|
| 866 |
+
left: 4px;
|
| 867 |
+
top: 1px;
|
| 868 |
+
width: 5px;
|
| 869 |
+
height: 9px;
|
| 870 |
+
border: solid #09090B;
|
| 871 |
+
border-width: 0 2px 2px 0;
|
| 872 |
+
transform: rotate(45deg);
|
| 873 |
+
}
|
| 874 |
+
.invoice-checkbox:hover {
|
| 875 |
+
border-color: rgba(245,158,11,0.5);
|
| 876 |
+
}
|
| 877 |
+
|
| 878 |
+
/* ========================================
|
| 879 |
+
Dashboard Footer
|
| 880 |
+
======================================== */
|
| 881 |
+
|
| 882 |
+
.dash-footer {
|
| 883 |
+
border-top: 1px solid rgba(255,255,255,0.06);
|
| 884 |
+
background: rgba(255,255,255,0.01);
|
| 885 |
+
margin-top: auto;
|
| 886 |
+
}
|
| 887 |
+
|
| 888 |
+
/* ========================================
|
| 889 |
+
Focus Glow (Subtle Glow on Focus)
|
| 890 |
+
======================================== */
|
| 891 |
+
|
| 892 |
+
.focus-glow:focus-visible {
|
| 893 |
+
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.15);
|
| 894 |
+
border-color: rgba(245, 158, 11, 0.4);
|
| 895 |
+
}
|
| 896 |
+
|
| 897 |
+
/* ========================================
|
| 898 |
+
Animated Counter (Number Pop)
|
| 899 |
+
======================================== */
|
| 900 |
+
|
| 901 |
+
@keyframes numberPop {
|
| 902 |
+
0% { transform: scale(0.8); opacity: 0; }
|
| 903 |
+
60% { transform: scale(1.05); }
|
| 904 |
+
100% { transform: scale(1); opacity: 1; }
|
| 905 |
+
}
|
| 906 |
+
.number-pop {
|
| 907 |
+
animation: numberPop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
| 908 |
+
}
|
| 909 |
+
|
| 910 |
+
/* ========================================
|
| 911 |
+
Select Dropdown Styling
|
| 912 |
+
======================================== */
|
| 913 |
+
|
| 914 |
+
.select-field {
|
| 915 |
+
background: rgba(255, 255, 255, 0.04);
|
| 916 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 917 |
+
border-radius: 10px;
|
| 918 |
+
padding: 10px 16px;
|
| 919 |
+
color: #FAFAFA;
|
| 920 |
+
font-size: 14px;
|
| 921 |
+
transition: all 0.2s ease;
|
| 922 |
+
width: 100%;
|
| 923 |
+
appearance: none;
|
| 924 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23A1A1AA' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
| 925 |
+
background-repeat: no-repeat;
|
| 926 |
+
background-position: right 12px center;
|
| 927 |
+
padding-right: 36px;
|
| 928 |
+
}
|
| 929 |
+
.select-field:focus {
|
| 930 |
+
outline: none;
|
| 931 |
+
border-color: #F59E0B;
|
| 932 |
+
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.1);
|
| 933 |
+
}
|
| 934 |
+
|
| 935 |
+
/* ========================================
|
| 936 |
+
Chat Suggestion Chip Hover Glow
|
| 937 |
+
======================================== */
|
| 938 |
+
|
| 939 |
+
.chat-suggestion:hover {
|
| 940 |
+
box-shadow: 0 0 12px rgba(245, 158, 11, 0.1);
|
| 941 |
+
border-color: rgba(245, 158, 11, 0.2);
|
| 942 |
+
}
|
| 943 |
+
|
| 944 |
+
/* ========================================
|
| 945 |
+
Safe Area Padding for iOS
|
| 946 |
+
======================================== */
|
| 947 |
+
|
| 948 |
+
.pb-safe {
|
| 949 |
+
padding-bottom: max(16px, env(safe-area-inset-bottom));
|
| 950 |
+
}
|
| 951 |
+
|
| 952 |
+
/* ========================================
|
| 953 |
+
View Transition & Micro-interactions
|
| 954 |
+
======================================== */
|
| 955 |
+
|
| 956 |
+
/* Page/View transition */
|
| 957 |
+
@keyframes viewEnter {
|
| 958 |
+
from {
|
| 959 |
+
opacity: 0;
|
| 960 |
+
transform: translateY(12px) scale(0.99);
|
| 961 |
+
}
|
| 962 |
+
to {
|
| 963 |
+
opacity: 1;
|
| 964 |
+
transform: translateY(0) scale(1);
|
| 965 |
+
}
|
| 966 |
+
}
|
| 967 |
+
.view-enter {
|
| 968 |
+
animation: viewEnter 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
|
| 969 |
+
}
|
| 970 |
+
|
| 971 |
+
/* Staggered card entrance */
|
| 972 |
+
@keyframes cardEnter {
|
| 973 |
+
from {
|
| 974 |
+
opacity: 0;
|
| 975 |
+
transform: translateY(16px);
|
| 976 |
+
}
|
| 977 |
+
to {
|
| 978 |
+
opacity: 1;
|
| 979 |
+
transform: translateY(0);
|
| 980 |
+
}
|
| 981 |
+
}
|
| 982 |
+
.card-enter {
|
| 983 |
+
opacity: 0;
|
| 984 |
+
animation: cardEnter 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
|
| 985 |
+
}
|
| 986 |
+
.card-enter:nth-child(1) { animation-delay: 0ms; }
|
| 987 |
+
.card-enter:nth-child(2) { animation-delay: 60ms; }
|
| 988 |
+
.card-enter:nth-child(3) { animation-delay: 120ms; }
|
| 989 |
+
.card-enter:nth-child(4) { animation-delay: 180ms; }
|
| 990 |
+
.card-enter:nth-child(5) { animation-delay: 240ms; }
|
| 991 |
+
.card-enter:nth-child(6) { animation-delay: 300ms; }
|
| 992 |
+
|
| 993 |
+
/* Animated Stat Counter (for landing page stats) */
|
| 994 |
+
@keyframes countSlideUp {
|
| 995 |
+
from { opacity: 0; transform: translateY(20px); }
|
| 996 |
+
to { opacity: 1; transform: translateY(0); }
|
| 997 |
+
}
|
| 998 |
+
.stat-animate {
|
| 999 |
+
opacity: 0;
|
| 1000 |
+
animation: countSlideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
|
| 1001 |
+
}
|
| 1002 |
+
.stat-animate:nth-child(1) { animation-delay: 0.1s; }
|
| 1003 |
+
.stat-animate:nth-child(2) { animation-delay: 0.2s; }
|
| 1004 |
+
.stat-animate:nth-child(3) { animation-delay: 0.3s; }
|
| 1005 |
+
|
| 1006 |
+
/* Enhanced Feature Cards with Gradient Border on Hover */
|
| 1007 |
+
.feature-card-gradient {
|
| 1008 |
+
position: relative;
|
| 1009 |
+
background: rgba(255,255,255,0.03);
|
| 1010 |
+
backdrop-filter: blur(12px);
|
| 1011 |
+
border: 1px solid rgba(255,255,255,0.06);
|
| 1012 |
+
border-radius: 16px;
|
| 1013 |
+
overflow: hidden;
|
| 1014 |
+
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
| 1015 |
+
}
|
| 1016 |
+
.feature-card-gradient::before {
|
| 1017 |
+
content: '';
|
| 1018 |
+
position: absolute;
|
| 1019 |
+
inset: 0;
|
| 1020 |
+
border-radius: 16px;
|
| 1021 |
+
padding: 1px;
|
| 1022 |
+
background: linear-gradient(135deg, transparent 40%, rgba(245,158,11,0.3) 100%);
|
| 1023 |
+
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
| 1024 |
+
-webkit-mask-composite: xor;
|
| 1025 |
+
mask-composite: exclude;
|
| 1026 |
+
opacity: 0;
|
| 1027 |
+
transition: opacity 0.4s ease;
|
| 1028 |
+
pointer-events: none;
|
| 1029 |
+
}
|
| 1030 |
+
.feature-card-gradient:hover::before {
|
| 1031 |
+
opacity: 1;
|
| 1032 |
+
}
|
| 1033 |
+
.feature-card-gradient:hover {
|
| 1034 |
+
transform: translateY(-6px);
|
| 1035 |
+
box-shadow: 0 20px 60px rgba(0,0,0,0.3), 0 0 40px rgba(245,158,11,0.05);
|
| 1036 |
+
}
|
| 1037 |
+
|
| 1038 |
+
/* Pricing Card "Most Popular" ribbon */
|
| 1039 |
+
.pricing-ribbon {
|
| 1040 |
+
position: absolute;
|
| 1041 |
+
top: -1px;
|
| 1042 |
+
right: 24px;
|
| 1043 |
+
background: linear-gradient(135deg, #F59E0B, #D97706);
|
| 1044 |
+
color: #09090B;
|
| 1045 |
+
font-size: 11px;
|
| 1046 |
+
font-weight: 700;
|
| 1047 |
+
padding: 4px 12px 6px;
|
| 1048 |
+
border-radius: 0 0 8px 8px;
|
| 1049 |
+
letter-spacing: 0.04em;
|
| 1050 |
+
text-transform: uppercase;
|
| 1051 |
+
}
|
| 1052 |
+
|
| 1053 |
+
/* Testimonial Card Enhancements */
|
| 1054 |
+
.testimonial-card {
|
| 1055 |
+
position: relative;
|
| 1056 |
+
background: rgba(255,255,255,0.03);
|
| 1057 |
+
backdrop-filter: blur(12px);
|
| 1058 |
+
border: 1px solid rgba(255,255,255,0.06);
|
| 1059 |
+
border-radius: 16px;
|
| 1060 |
+
padding: 24px;
|
| 1061 |
+
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
| 1062 |
+
}
|
| 1063 |
+
.testimonial-card:hover {
|
| 1064 |
+
transform: translateY(-4px) scale(1.01);
|
| 1065 |
+
border-color: rgba(245,158,11,0.15);
|
| 1066 |
+
box-shadow: 0 16px 48px rgba(0,0,0,0.2);
|
| 1067 |
+
}
|
| 1068 |
+
.testimonial-card::after {
|
| 1069 |
+
content: '';
|
| 1070 |
+
position: absolute;
|
| 1071 |
+
bottom: 0;
|
| 1072 |
+
left: 24px;
|
| 1073 |
+
right: 24px;
|
| 1074 |
+
height: 2px;
|
| 1075 |
+
background: linear-gradient(90deg, transparent, rgba(245,158,11,0.3), transparent);
|
| 1076 |
+
opacity: 0;
|
| 1077 |
+
transition: opacity 0.4s ease;
|
| 1078 |
+
}
|
| 1079 |
+
.testimonial-card:hover::after {
|
| 1080 |
+
opacity: 1;
|
| 1081 |
+
}
|
| 1082 |
+
|
| 1083 |
+
/* Hero Particle/Dot Grid Enhancement */
|
| 1084 |
+
.hero-grid-enhanced {
|
| 1085 |
+
background-image:
|
| 1086 |
+
radial-gradient(circle at 1px 1px, rgba(245,158,11,0.08) 1px, transparent 0);
|
| 1087 |
+
background-size: 40px 40px;
|
| 1088 |
+
}
|
| 1089 |
+
|
| 1090 |
+
/* Glow divider between sections */
|
| 1091 |
+
.section-glow-divider {
|
| 1092 |
+
height: 1px;
|
| 1093 |
+
background: linear-gradient(90deg, transparent, rgba(245,158,11,0.2) 50%, transparent);
|
| 1094 |
+
border: none;
|
| 1095 |
+
}
|
| 1096 |
+
|
| 1097 |
+
/* Tab content area subtle background */
|
| 1098 |
+
.tab-content-area {
|
| 1099 |
+
background: linear-gradient(180deg, rgba(255,255,255,0.01) 0%, transparent 100%);
|
| 1100 |
+
border-radius: 12px;
|
| 1101 |
+
padding: 24px;
|
| 1102 |
+
}
|
| 1103 |
+
|
| 1104 |
+
/* Activity timeline line animation */
|
| 1105 |
+
@keyframes timelineGrow {
|
| 1106 |
+
from { transform: scaleY(0); }
|
| 1107 |
+
to { transform: scaleY(1); }
|
| 1108 |
+
}
|
| 1109 |
+
.timeline-line-animated {
|
| 1110 |
+
transform-origin: top;
|
| 1111 |
+
animation: timelineGrow 0.8s cubic-bezier(0.16, 1, 0.3, 1) both;
|
| 1112 |
+
animation-delay: 0.3s;
|
| 1113 |
+
}
|
| 1114 |
+
|
| 1115 |
+
/* Enhanced input focus with glow ring */
|
| 1116 |
+
.input-field-glow:focus {
|
| 1117 |
+
outline: none;
|
| 1118 |
+
border-color: rgba(245, 158, 11, 0.5);
|
| 1119 |
+
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.08), 0 0 20px rgba(245, 158, 11, 0.06);
|
| 1120 |
+
background: rgba(255, 255, 255, 0.06);
|
| 1121 |
+
}
|
| 1122 |
+
|
| 1123 |
+
/* Subtle hover underline for footer links */
|
| 1124 |
+
.footer-link-underline {
|
| 1125 |
+
position: relative;
|
| 1126 |
+
color: #A1A1AA;
|
| 1127 |
+
transition: color 0.2s ease;
|
| 1128 |
+
cursor: pointer;
|
| 1129 |
+
}
|
| 1130 |
+
.footer-link-underline::after {
|
| 1131 |
+
content: '';
|
| 1132 |
+
position: absolute;
|
| 1133 |
+
bottom: -2px;
|
| 1134 |
+
left: 0;
|
| 1135 |
+
width: 0;
|
| 1136 |
+
height: 1px;
|
| 1137 |
+
background: #F59E0B;
|
| 1138 |
+
transition: width 0.3s ease;
|
| 1139 |
+
}
|
| 1140 |
+
.footer-link-underline:hover {
|
| 1141 |
+
color: #F59E0B;
|
| 1142 |
+
}
|
| 1143 |
+
.footer-link-underline:hover::after {
|
| 1144 |
+
width: 100%;
|
| 1145 |
+
}
|
| 1146 |
+
|
| 1147 |
+
/* ── Premium CSS Enhancements ── */
|
| 1148 |
+
|
| 1149 |
+
/* CTA button shine sweep effect */
|
| 1150 |
+
.btn-shine {
|
| 1151 |
+
position: relative;
|
| 1152 |
+
overflow: hidden;
|
| 1153 |
+
}
|
| 1154 |
+
.btn-shine::before {
|
| 1155 |
+
content: '';
|
| 1156 |
+
position: absolute;
|
| 1157 |
+
top: 0;
|
| 1158 |
+
left: -100%;
|
| 1159 |
+
width: 100%;
|
| 1160 |
+
height: 100%;
|
| 1161 |
+
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
| 1162 |
+
transition: left 0.5s ease;
|
| 1163 |
+
}
|
| 1164 |
+
.btn-shine:hover::before {
|
| 1165 |
+
left: 100%;
|
| 1166 |
+
}
|
| 1167 |
+
|
| 1168 |
+
/* Floating badge animation on hero */
|
| 1169 |
+
@keyframes float {
|
| 1170 |
+
0%, 100% { transform: translateY(0px); }
|
| 1171 |
+
50% { transform: translateY(-8px); }
|
| 1172 |
+
}
|
| 1173 |
+
.float-animation {
|
| 1174 |
+
animation: float 3s ease-in-out infinite;
|
| 1175 |
+
}
|
| 1176 |
+
.float-animation-delayed {
|
| 1177 |
+
animation: float 3s ease-in-out 1.5s infinite;
|
| 1178 |
+
}
|
| 1179 |
+
|
| 1180 |
+
/* Logo pulse glow */
|
| 1181 |
+
@keyframes logoGlow {
|
| 1182 |
+
0%, 100% { box-shadow: 0 0 12px rgba(245, 158, 11, 0.2); }
|
| 1183 |
+
50% { box-shadow: 0 0 24px rgba(245, 158, 11, 0.4); }
|
| 1184 |
+
}
|
| 1185 |
+
.logo-glow {
|
| 1186 |
+
animation: logoGlow 3s ease-in-out infinite;
|
| 1187 |
+
}
|
| 1188 |
+
|
| 1189 |
+
/* Enhanced glassmorphism for stat cards */
|
| 1190 |
+
.stat-card-glass {
|
| 1191 |
+
background: rgba(255, 255, 255, 0.03);
|
| 1192 |
+
backdrop-filter: blur(12px);
|
| 1193 |
+
border: 1px solid rgba(255, 255, 255, 0.06);
|
| 1194 |
+
border-radius: 12px;
|
| 1195 |
+
padding: 16px 20px;
|
| 1196 |
+
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
| 1197 |
+
position: relative;
|
| 1198 |
+
overflow: hidden;
|
| 1199 |
+
}
|
| 1200 |
+
.stat-card-glass::before {
|
| 1201 |
+
content: '';
|
| 1202 |
+
position: absolute;
|
| 1203 |
+
top: 0;
|
| 1204 |
+
left: 0;
|
| 1205 |
+
right: 0;
|
| 1206 |
+
height: 2px;
|
| 1207 |
+
border-radius: 12px 12px 0 0;
|
| 1208 |
+
}
|
| 1209 |
+
.stat-card-glass.card-accent-amber::before {
|
| 1210 |
+
background: linear-gradient(90deg, transparent, rgba(245, 158, 11, 0.6), transparent);
|
| 1211 |
+
}
|
| 1212 |
+
.stat-card-glass.card-accent-teal::before {
|
| 1213 |
+
background: linear-gradient(90deg, transparent, rgba(20, 184, 166, 0.6), transparent);
|
| 1214 |
+
}
|
| 1215 |
+
.stat-card-glass.card-accent-red::before {
|
| 1216 |
+
background: linear-gradient(90deg, transparent, rgba(239, 68, 68, 0.6), transparent);
|
| 1217 |
+
}
|
| 1218 |
+
.stat-card-glass.card-accent-green::before {
|
| 1219 |
+
background: linear-gradient(90deg, transparent, rgba(34, 197, 94, 0.6), transparent);
|
| 1220 |
+
}
|
| 1221 |
+
.stat-card-glass:hover {
|
| 1222 |
+
transform: translateY(-3px);
|
| 1223 |
+
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
| 1224 |
+
border-color: rgba(255, 255, 255, 0.1);
|
| 1225 |
+
}
|
| 1226 |
+
|
| 1227 |
+
/* Enhanced table row hover with left accent bar */
|
| 1228 |
+
.invoice-row-enhanced {
|
| 1229 |
+
cursor: pointer;
|
| 1230 |
+
transition: all 0.2s ease;
|
| 1231 |
+
position: relative;
|
| 1232 |
+
}
|
| 1233 |
+
.invoice-row-enhanced:hover {
|
| 1234 |
+
background: rgba(255, 255, 255, 0.03) !important;
|
| 1235 |
+
}
|
| 1236 |
+
.invoice-row-enhanced:hover td:first-child {
|
| 1237 |
+
box-shadow: inset 3px 0 0 -1px rgba(245, 158, 11, 0.5);
|
| 1238 |
+
}
|
| 1239 |
+
|
| 1240 |
+
/* Custom recharts tooltip */
|
| 1241 |
+
.recharts-tooltip-custom {
|
| 1242 |
+
background: rgba(17, 17, 19, 0.95) !important;
|
| 1243 |
+
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
| 1244 |
+
border-radius: 10px !important;
|
| 1245 |
+
padding: 10px 14px !important;
|
| 1246 |
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3) !important;
|
| 1247 |
+
backdrop-filter: blur(8px) !important;
|
| 1248 |
+
}
|
| 1249 |
+
.recharts-tooltip-custom .recharts-tooltip-label {
|
| 1250 |
+
color: #A1A1AA !important;
|
| 1251 |
+
font-size: 11px !important;
|
| 1252 |
+
font-weight: 500 !important;
|
| 1253 |
+
text-transform: uppercase !important;
|
| 1254 |
+
letter-spacing: 0.05em !important;
|
| 1255 |
+
}
|
| 1256 |
+
.recharts-tooltip-custom .recharts-tooltip-value {
|
| 1257 |
+
color: #FAFAFA !important;
|
| 1258 |
+
font-size: 13px !important;
|
| 1259 |
+
font-weight: 600 !important;
|
| 1260 |
+
}
|
| 1261 |
+
|
| 1262 |
+
/* Smooth mobile nav slide down */
|
| 1263 |
+
@keyframes slideDown {
|
| 1264 |
+
from { opacity: 0; transform: translateY(-8px); max-height: 0; }
|
| 1265 |
+
to { opacity: 1; transform: translateY(0); max-height: 400px; }
|
| 1266 |
+
}
|
| 1267 |
+
.mobile-nav-slide {
|
| 1268 |
+
animation: slideDown 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
|
| 1269 |
+
overflow: hidden;
|
| 1270 |
+
}
|
| 1271 |
+
|
| 1272 |
+
/* How-it-works step connector line */
|
| 1273 |
+
.step-connector {
|
| 1274 |
+
position: relative;
|
| 1275 |
+
}
|
| 1276 |
+
.step-connector::after {
|
| 1277 |
+
content: '';
|
| 1278 |
+
position: absolute;
|
| 1279 |
+
top: 28px;
|
| 1280 |
+
left: calc(50% + 32px);
|
| 1281 |
+
width: calc(100% - 64px);
|
| 1282 |
+
height: 2px;
|
| 1283 |
+
background: linear-gradient(90deg, rgba(245, 158, 11, 0.3), rgba(20, 184, 166, 0.3));
|
| 1284 |
+
}
|
| 1285 |
+
.step-connector:last-child::after {
|
| 1286 |
+
display: none;
|
| 1287 |
+
}
|
| 1288 |
+
|
| 1289 |
+
/* Upload success animation */
|
| 1290 |
+
@keyframes successPop {
|
| 1291 |
+
0% { transform: scale(0.8); opacity: 0; }
|
| 1292 |
+
50% { transform: scale(1.05); }
|
| 1293 |
+
100% { transform: scale(1); opacity: 1; }
|
| 1294 |
+
}
|
| 1295 |
+
.success-pop {
|
| 1296 |
+
animation: successPop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
| 1297 |
+
}
|
| 1298 |
+
|
| 1299 |
+
/* Subtle hover effect for FAQ details */
|
| 1300 |
+
details.glass-card summary {
|
| 1301 |
+
transition: background 0.2s ease;
|
| 1302 |
+
}
|
| 1303 |
+
details.glass-card summary:hover {
|
| 1304 |
+
background: rgba(255, 255, 255, 0.02);
|
| 1305 |
+
}
|
| 1306 |
+
details.glass-card[open] summary {
|
| 1307 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
| 1308 |
+
}
|
| 1309 |
+
|
| 1310 |
+
/* ========================================
|
| 1311 |
+
Light Theme Overrides
|
| 1312 |
+
======================================== */
|
| 1313 |
+
|
| 1314 |
+
/* Base light theme */
|
| 1315 |
+
.light {
|
| 1316 |
+
--background: #FAFAFA;
|
| 1317 |
+
--foreground: #18181B;
|
| 1318 |
+
--card: #FFFFFF;
|
| 1319 |
+
--card-foreground: #18181B;
|
| 1320 |
+
--popover: #FFFFFF;
|
| 1321 |
+
--popover-foreground: #18181B;
|
| 1322 |
+
--primary: #D97706;
|
| 1323 |
+
--primary-foreground: #FFFFFF;
|
| 1324 |
+
--secondary: #F4F4F5;
|
| 1325 |
+
--secondary-foreground: #18181B;
|
| 1326 |
+
--muted: #F4F4F5;
|
| 1327 |
+
--muted-foreground: #71717A;
|
| 1328 |
+
--accent: #F4F4F5;
|
| 1329 |
+
--accent-foreground: #18181B;
|
| 1330 |
+
--destructive: #EF4444;
|
| 1331 |
+
--border: rgba(0, 0, 0, 0.08);
|
| 1332 |
+
--input: rgba(0, 0, 0, 0.08);
|
| 1333 |
+
--ring: #D97706;
|
| 1334 |
+
color-scheme: light;
|
| 1335 |
+
}
|
| 1336 |
+
|
| 1337 |
+
.light body {
|
| 1338 |
+
background: #FAFAFA;
|
| 1339 |
+
color: #18181B;
|
| 1340 |
+
}
|
| 1341 |
+
|
| 1342 |
+
/* Light glass cards */
|
| 1343 |
+
.light .glass-card,
|
| 1344 |
+
.light .glass-card-hover {
|
| 1345 |
+
background: rgba(255, 255, 255, 0.8);
|
| 1346 |
+
border-color: rgba(0, 0, 0, 0.06);
|
| 1347 |
+
backdrop-filter: blur(12px);
|
| 1348 |
+
}
|
| 1349 |
+
|
| 1350 |
+
.light .glass-card-hover:hover {
|
| 1351 |
+
background: rgba(255, 255, 255, 0.95);
|
| 1352 |
+
border-color: rgba(217, 119, 6, 0.2);
|
| 1353 |
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.06);
|
| 1354 |
+
}
|
| 1355 |
+
|
| 1356 |
+
/* Light navbar */
|
| 1357 |
+
.light .nav-blur {
|
| 1358 |
+
background: rgba(250, 250, 250, 0.85);
|
| 1359 |
+
border-bottom-color: rgba(0, 0, 0, 0.06);
|
| 1360 |
+
}
|
| 1361 |
+
|
| 1362 |
+
/* Light input fields */
|
| 1363 |
+
.light .input-field,
|
| 1364 |
+
.light .input-field-glow {
|
| 1365 |
+
background: #FFFFFF;
|
| 1366 |
+
border-color: rgba(0, 0, 0, 0.1);
|
| 1367 |
+
color: #18181B;
|
| 1368 |
+
}
|
| 1369 |
+
.light .input-field:focus,
|
| 1370 |
+
.light .input-field-glow:focus {
|
| 1371 |
+
border-color: #D97706;
|
| 1372 |
+
box-shadow: 0 0 0 3px rgba(217, 119, 6, 0.1), 0 0 20px rgba(217, 119, 6, 0.04);
|
| 1373 |
+
background: #FFFFFF;
|
| 1374 |
+
}
|
| 1375 |
+
.light .input-field::placeholder,
|
| 1376 |
+
.light .input-field-glow::placeholder {
|
| 1377 |
+
color: #A1A1AA;
|
| 1378 |
+
}
|
| 1379 |
+
|
| 1380 |
+
/* Light buttons */
|
| 1381 |
+
.light .btn-ghost {
|
| 1382 |
+
color: #18181B;
|
| 1383 |
+
border-color: rgba(0, 0, 0, 0.1);
|
| 1384 |
+
}
|
| 1385 |
+
.light .btn-ghost:hover {
|
| 1386 |
+
background: rgba(0, 0, 0, 0.04);
|
| 1387 |
+
border-color: rgba(0, 0, 0, 0.15);
|
| 1388 |
+
}
|
| 1389 |
+
.light .btn-primary {
|
| 1390 |
+
background: linear-gradient(135deg, #D97706 0%, #B45309 100%);
|
| 1391 |
+
color: #FFFFFF;
|
| 1392 |
+
}
|
| 1393 |
+
|
| 1394 |
+
/* Light data table */
|
| 1395 |
+
.light .data-table thead th {
|
| 1396 |
+
color: #71717A;
|
| 1397 |
+
border-bottom-color: rgba(0, 0, 0, 0.06);
|
| 1398 |
+
background: #FAFAFA;
|
| 1399 |
+
}
|
| 1400 |
+
.light .data-table tbody td {
|
| 1401 |
+
color: #52525B;
|
| 1402 |
+
border-bottom-color: rgba(0, 0, 0, 0.04);
|
| 1403 |
+
}
|
| 1404 |
+
|
| 1405 |
+
/* Light stat card */
|
| 1406 |
+
.light .stat-card {
|
| 1407 |
+
background: #FFFFFF;
|
| 1408 |
+
border-color: rgba(0, 0, 0, 0.06);
|
| 1409 |
+
}
|
| 1410 |
+
.light .stat-card:hover {
|
| 1411 |
+
background: #FAFAFA;
|
| 1412 |
+
border-color: rgba(0, 0, 0, 0.1);
|
| 1413 |
+
}
|
| 1414 |
+
|
| 1415 |
+
/* Light drop zone */
|
| 1416 |
+
.light .drop-zone,
|
| 1417 |
+
.light .drop-zone-animated {
|
| 1418 |
+
border-color: rgba(217, 119, 6, 0.3);
|
| 1419 |
+
background: rgba(217, 119, 6, 0.02);
|
| 1420 |
+
}
|
| 1421 |
+
.light .drop-zone:hover,
|
| 1422 |
+
.light .drop-zone.drag-over {
|
| 1423 |
+
border-color: #D97706;
|
| 1424 |
+
background: rgba(217, 119, 6, 0.04);
|
| 1425 |
+
}
|
| 1426 |
+
|
| 1427 |
+
/* Light invoice row */
|
| 1428 |
+
.light .invoice-row:hover {
|
| 1429 |
+
background: rgba(0, 0, 0, 0.02) !important;
|
| 1430 |
+
}
|
| 1431 |
+
|
| 1432 |
+
/* Light code block */
|
| 1433 |
+
.light .code-block {
|
| 1434 |
+
background: #F4F4F5;
|
| 1435 |
+
border-color: rgba(0, 0, 0, 0.06);
|
| 1436 |
+
color: #52525B;
|
| 1437 |
+
}
|
| 1438 |
+
|
| 1439 |
+
/* Light modal */
|
| 1440 |
+
.light .modal-overlay {
|
| 1441 |
+
background: rgba(0, 0, 0, 0.4);
|
| 1442 |
+
}
|
| 1443 |
+
.light .modal-content {
|
| 1444 |
+
background: #FFFFFF;
|
| 1445 |
+
border-color: rgba(0, 0, 0, 0.08);
|
| 1446 |
+
}
|
| 1447 |
+
|
| 1448 |
+
/* Light notification dropdown */
|
| 1449 |
+
.light .notification-dropdown {
|
| 1450 |
+
background: #FFFFFF;
|
| 1451 |
+
border-color: rgba(0, 0, 0, 0.08);
|
| 1452 |
+
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.12);
|
| 1453 |
+
}
|
| 1454 |
+
.light .notification-item:hover {
|
| 1455 |
+
background: rgba(0, 0, 0, 0.02);
|
| 1456 |
+
}
|
| 1457 |
+
|
| 1458 |
+
/* Light tooltips */
|
| 1459 |
+
.light .tooltip-wrapper .tooltip-text {
|
| 1460 |
+
background: #27272A;
|
| 1461 |
+
color: #FAFAFA;
|
| 1462 |
+
}
|
| 1463 |
+
|
| 1464 |
+
/* Light kbd */
|
| 1465 |
+
.light .kbd {
|
| 1466 |
+
color: #71717A;
|
| 1467 |
+
background: #F4F4F5;
|
| 1468 |
+
border-color: rgba(0, 0, 0, 0.1);
|
| 1469 |
+
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.05);
|
| 1470 |
+
}
|
| 1471 |
+
|
| 1472 |
+
/* Light footer */
|
| 1473 |
+
.light .dash-footer {
|
| 1474 |
+
border-top-color: rgba(0, 0, 0, 0.06);
|
| 1475 |
+
background: rgba(0, 0, 0, 0.01);
|
| 1476 |
+
}
|
| 1477 |
+
|
| 1478 |
+
/* Light scroll-to-top */
|
| 1479 |
+
.light .scroll-top-btn {
|
| 1480 |
+
background: rgba(217, 119, 6, 0.1);
|
| 1481 |
+
border-color: rgba(217, 119, 6, 0.3);
|
| 1482 |
+
color: #D97706;
|
| 1483 |
+
}
|
| 1484 |
+
.light .scroll-top-btn:hover {
|
| 1485 |
+
background: rgba(217, 119, 6, 0.2);
|
| 1486 |
+
}
|
| 1487 |
+
|
| 1488 |
+
/* Light skeleton shimmer */
|
| 1489 |
+
.light .skeleton-shimmer {
|
| 1490 |
+
background: linear-gradient(90deg, rgba(0,0,0,0.03) 25%, rgba(0,0,0,0.07) 50%, rgba(0,0,0,0.03) 75%);
|
| 1491 |
+
background-size: 200% 100%;
|
| 1492 |
+
animation: shimmer 1.5s infinite;
|
| 1493 |
+
border-radius: 8px;
|
| 1494 |
+
}
|
| 1495 |
+
|
| 1496 |
+
/* Light grid background */
|
| 1497 |
+
.light .grid-bg {
|
| 1498 |
+
background-image:
|
| 1499 |
+
linear-gradient(rgba(0,0,0,0.03) 1px, transparent 1px),
|
| 1500 |
+
linear-gradient(90deg, rgba(0,0,0,0.03) 1px, transparent 1px);
|
| 1501 |
+
}
|
| 1502 |
+
|
| 1503 |
+
/* Light hero orbs */
|
| 1504 |
+
.light .hero-orb {
|
| 1505 |
+
background: radial-gradient(circle, rgba(217,119,6,0.08) 0%, rgba(13,148,136,0.04) 40%, transparent 70%);
|
| 1506 |
+
}
|
| 1507 |
+
.light .hero-orb-2 {
|
| 1508 |
+
background: radial-gradient(circle, rgba(13,148,136,0.06) 0%, rgba(217,119,6,0.03) 40%, transparent 70%);
|
| 1509 |
+
}
|
| 1510 |
+
|
| 1511 |
+
/* Light hero dot grid */
|
| 1512 |
+
.light .hero-grid-enhanced {
|
| 1513 |
+
background-image:
|
| 1514 |
+
radial-gradient(circle at 1px 1px, rgba(217,119,6,0.06) 1px, transparent 0);
|
| 1515 |
+
}
|
| 1516 |
+
|
| 1517 |
+
/* Light section glow divider */
|
| 1518 |
+
.light .section-glow-divider {
|
| 1519 |
+
background: linear-gradient(90deg, transparent, rgba(217,119,6,0.15) 50%, transparent);
|
| 1520 |
+
}
|
| 1521 |
+
|
| 1522 |
+
/* Light feature card gradient */
|
| 1523 |
+
.light .feature-card-gradient {
|
| 1524 |
+
background: rgba(255,255,255,0.8);
|
| 1525 |
+
border-color: rgba(0,0,0,0.06);
|
| 1526 |
+
}
|
| 1527 |
+
.light .feature-card-gradient::before {
|
| 1528 |
+
background: linear-gradient(135deg, transparent 40%, rgba(217,119,6,0.2) 100%);
|
| 1529 |
+
}
|
| 1530 |
+
.light .feature-card-gradient:hover {
|
| 1531 |
+
box-shadow: 0 20px 60px rgba(0,0,0,0.08), 0 0 40px rgba(217,119,6,0.03);
|
| 1532 |
+
}
|
| 1533 |
+
|
| 1534 |
+
/* Light testimonial card */
|
| 1535 |
+
.light .testimonial-card {
|
| 1536 |
+
background: rgba(255,255,255,0.8);
|
| 1537 |
+
border-color: rgba(0,0,0,0.06);
|
| 1538 |
+
}
|
| 1539 |
+
.light .testimonial-card:hover {
|
| 1540 |
+
border-color: rgba(217,119,6,0.15);
|
| 1541 |
+
box-shadow: 0 16px 48px rgba(0,0,0,0.08);
|
| 1542 |
+
}
|
| 1543 |
+
.light .testimonial-card::after {
|
| 1544 |
+
background: linear-gradient(90deg, transparent, rgba(217,119,6,0.2), transparent);
|
| 1545 |
+
}
|
| 1546 |
+
|
| 1547 |
+
/* Light pricing highlight */
|
| 1548 |
+
.light .pricing-highlight {
|
| 1549 |
+
border-color: rgba(217,119,6,0.3);
|
| 1550 |
+
background: linear-gradient(135deg, rgba(217,119,6,0.05) 0%, rgba(13,148,136,0.02) 100%);
|
| 1551 |
+
}
|
| 1552 |
+
|
| 1553 |
+
/* Light tab content area */
|
| 1554 |
+
.light .tab-content-area {
|
| 1555 |
+
background: linear-gradient(180deg, rgba(0,0,0,0.01) 0%, transparent 100%);
|
| 1556 |
+
}
|
| 1557 |
+
|
| 1558 |
+
/* Light noise overlay (very subtle) */
|
| 1559 |
+
.light .noise-overlay::before {
|
| 1560 |
+
opacity: 0.015;
|
| 1561 |
+
}
|
| 1562 |
+
|
| 1563 |
+
/* Light select field */
|
| 1564 |
+
.light .select-field {
|
| 1565 |
+
background: #FFFFFF;
|
| 1566 |
+
border-color: rgba(0, 0, 0, 0.1);
|
| 1567 |
+
color: #18181B;
|
| 1568 |
+
}
|
| 1569 |
+
.light .select-field:focus {
|
| 1570 |
+
border-color: #D97706;
|
| 1571 |
+
box-shadow: 0 0 0 3px rgba(217, 119, 6, 0.1);
|
| 1572 |
+
}
|
| 1573 |
+
|
| 1574 |
+
/* Light badge styles */
|
| 1575 |
+
.light .badge-amber {
|
| 1576 |
+
background: rgba(217, 119, 6, 0.1);
|
| 1577 |
+
color: #B45309;
|
| 1578 |
+
}
|
| 1579 |
+
.light .badge-teal {
|
| 1580 |
+
background: rgba(13, 184, 166, 0.1);
|
| 1581 |
+
color: #0D9488;
|
| 1582 |
+
}
|
| 1583 |
+
|
| 1584 |
+
/* Light status badges */
|
| 1585 |
+
.light .status-done { background: rgba(34, 197, 94, 0.1); color: #16A34A; }
|
| 1586 |
+
.light .status-review { background: rgba(234, 179, 8, 0.1); color: #CA8A04; }
|
| 1587 |
+
.light .status-duplicate { background: rgba(239, 68, 68, 0.1); color: #DC2626; }
|
| 1588 |
+
|
| 1589 |
+
/* Light chat bubbles */
|
| 1590 |
+
.light .chat-message-user {
|
| 1591 |
+
background: linear-gradient(135deg, #D97706 0%, #B45309 100%);
|
| 1592 |
+
color: #FFFFFF;
|
| 1593 |
+
}
|
| 1594 |
+
.light .chat-message-assistant {
|
| 1595 |
+
background: rgba(0, 0, 0, 0.04);
|
| 1596 |
+
color: #18181B;
|
| 1597 |
+
border-color: rgba(0, 0, 0, 0.06);
|
| 1598 |
+
}
|
| 1599 |
+
.light .chat-timestamp {
|
| 1600 |
+
color: rgba(0, 0, 0, 0.3);
|
| 1601 |
+
}
|
| 1602 |
+
|
| 1603 |
+
/* Light checkbox */
|
| 1604 |
+
.light .invoice-checkbox {
|
| 1605 |
+
border-color: rgba(0, 0, 0, 0.2);
|
| 1606 |
+
}
|
| 1607 |
+
.light .invoice-checkbox:checked {
|
| 1608 |
+
background: #D97706;
|
| 1609 |
+
border-color: #D97706;
|
| 1610 |
+
}
|
| 1611 |
+
.light .invoice-checkbox:hover {
|
| 1612 |
+
border-color: rgba(217, 119, 6, 0.5);
|
| 1613 |
+
}
|
| 1614 |
+
|
| 1615 |
+
/* Light amber glow */
|
| 1616 |
+
.light .amber-glow {
|
| 1617 |
+
box-shadow: 0 0 40px rgba(217, 119, 6, 0.1), 0 0 80px rgba(217, 119, 6, 0.03);
|
| 1618 |
+
}
|
| 1619 |
+
|
| 1620 |
+
/* Light focus visible */
|
| 1621 |
+
.light *:focus-visible {
|
| 1622 |
+
outline-color: #D97706;
|
| 1623 |
+
}
|
| 1624 |
+
|
| 1625 |
+
/* Light gradient text */
|
| 1626 |
+
.light .gradient-text {
|
| 1627 |
+
background: linear-gradient(135deg, #D97706 0%, #B45309 40%, #0D9488 100%);
|
| 1628 |
+
-webkit-background-clip: text;
|
| 1629 |
+
-webkit-text-fill-color: transparent;
|
| 1630 |
+
background-clip: text;
|
| 1631 |
+
}
|
| 1632 |
+
|
| 1633 |
+
/* Light scrollbar */
|
| 1634 |
+
.light ::-webkit-scrollbar-thumb {
|
| 1635 |
+
background: rgba(0, 0, 0, 0.12);
|
| 1636 |
+
}
|
| 1637 |
+
.light ::-webkit-scrollbar-thumb:hover {
|
| 1638 |
+
background: rgba(0, 0, 0, 0.2);
|
| 1639 |
+
}
|
| 1640 |
+
|
| 1641 |
+
/* ── Table Sort Indicators ── */
|
| 1642 |
+
.sort-header {
|
| 1643 |
+
cursor: pointer;
|
| 1644 |
+
user-select: none;
|
| 1645 |
+
transition: color 0.15s ease;
|
| 1646 |
+
}
|
| 1647 |
+
.sort-header:hover {
|
| 1648 |
+
color: var(--foreground) !important;
|
| 1649 |
+
}
|
| 1650 |
+
.sort-indicator {
|
| 1651 |
+
display: inline-flex;
|
| 1652 |
+
align-items: center;
|
| 1653 |
+
justify-content: center;
|
| 1654 |
+
width: 16px;
|
| 1655 |
+
height: 16px;
|
| 1656 |
+
font-size: 10px;
|
| 1657 |
+
color: #F59E0B;
|
| 1658 |
+
margin-left: 4px;
|
| 1659 |
+
animation: sortPop 0.2s ease;
|
| 1660 |
+
}
|
| 1661 |
+
@keyframes sortPop {
|
| 1662 |
+
0% { transform: scale(0.5); opacity: 0; }
|
| 1663 |
+
100% { transform: scale(1); opacity: 1; }
|
| 1664 |
+
}
|
| 1665 |
+
.sort-header-inactive {
|
| 1666 |
+
display: inline-flex;
|
| 1667 |
+
align-items: center;
|
| 1668 |
+
justify-content: center;
|
| 1669 |
+
width: 16px;
|
| 1670 |
+
height: 16px;
|
| 1671 |
+
margin-left: 4px;
|
| 1672 |
+
opacity: 0;
|
| 1673 |
+
transition: opacity 0.15s ease;
|
| 1674 |
+
}
|
| 1675 |
+
.sort-header:hover .sort-header-inactive {
|
| 1676 |
+
opacity: 0.3;
|
| 1677 |
+
}
|
| 1678 |
+
|
| 1679 |
+
/* ── Command Palette ── */
|
| 1680 |
+
.command-palette-backdrop {
|
| 1681 |
+
position: fixed;
|
| 1682 |
+
inset: 0;
|
| 1683 |
+
z-index: 60;
|
| 1684 |
+
background: rgba(0, 0, 0, 0.6);
|
| 1685 |
+
backdrop-filter: blur(8px);
|
| 1686 |
+
display: flex;
|
| 1687 |
+
align-items: flex-start;
|
| 1688 |
+
justify-content: center;
|
| 1689 |
+
padding-top: 15vh;
|
| 1690 |
+
animation: commandFadeIn 0.15s ease;
|
| 1691 |
+
}
|
| 1692 |
+
@keyframes commandFadeIn {
|
| 1693 |
+
from { opacity: 0; }
|
| 1694 |
+
to { opacity: 1; }
|
| 1695 |
+
}
|
| 1696 |
+
.command-palette {
|
| 1697 |
+
width: 100%;
|
| 1698 |
+
max-width: 560px;
|
| 1699 |
+
margin: 0 1rem;
|
| 1700 |
+
background: rgba(24, 24, 27, 0.95);
|
| 1701 |
+
backdrop-filter: blur(20px);
|
| 1702 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 1703 |
+
border-radius: 16px;
|
| 1704 |
+
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
| 1705 |
+
overflow: hidden;
|
| 1706 |
+
animation: commandSlideIn 0.2s ease;
|
| 1707 |
+
}
|
| 1708 |
+
@keyframes commandSlideIn {
|
| 1709 |
+
from { opacity: 0; transform: translateY(-10px) scale(0.98); }
|
| 1710 |
+
to { opacity: 1; transform: translateY(0) scale(1); }
|
| 1711 |
+
}
|
| 1712 |
+
.command-palette-input {
|
| 1713 |
+
display: flex;
|
| 1714 |
+
align-items: center;
|
| 1715 |
+
gap: 12px;
|
| 1716 |
+
padding: 16px;
|
| 1717 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
| 1718 |
+
}
|
| 1719 |
+
.command-palette-input input {
|
| 1720 |
+
flex: 1;
|
| 1721 |
+
background: transparent;
|
| 1722 |
+
border: none;
|
| 1723 |
+
outline: none;
|
| 1724 |
+
color: var(--foreground);
|
| 1725 |
+
font-size: 14px;
|
| 1726 |
+
}
|
| 1727 |
+
.command-palette-input input::placeholder {
|
| 1728 |
+
color: var(--muted-foreground);
|
| 1729 |
+
}
|
| 1730 |
+
.command-palette-results {
|
| 1731 |
+
max-height: 288px;
|
| 1732 |
+
overflow-y: auto;
|
| 1733 |
+
padding: 8px;
|
| 1734 |
+
}
|
| 1735 |
+
.command-palette-item {
|
| 1736 |
+
display: flex;
|
| 1737 |
+
align-items: center;
|
| 1738 |
+
gap: 12px;
|
| 1739 |
+
width: 100%;
|
| 1740 |
+
text-align: left;
|
| 1741 |
+
padding: 10px 12px;
|
| 1742 |
+
border-radius: 10px;
|
| 1743 |
+
font-size: 14px;
|
| 1744 |
+
color: var(--muted-foreground);
|
| 1745 |
+
background: transparent;
|
| 1746 |
+
border: none;
|
| 1747 |
+
cursor: pointer;
|
| 1748 |
+
transition: all 0.12s ease;
|
| 1749 |
+
}
|
| 1750 |
+
.command-palette-item:hover {
|
| 1751 |
+
color: var(--foreground);
|
| 1752 |
+
background: rgba(255, 255, 255, 0.04);
|
| 1753 |
+
}
|
| 1754 |
+
.command-palette-item:active {
|
| 1755 |
+
background: rgba(255, 255, 255, 0.06);
|
| 1756 |
+
}
|
| 1757 |
+
.command-palette-section-title {
|
| 1758 |
+
padding: 8px 12px 4px;
|
| 1759 |
+
font-size: 11px;
|
| 1760 |
+
font-weight: 600;
|
| 1761 |
+
text-transform: uppercase;
|
| 1762 |
+
letter-spacing: 0.05em;
|
| 1763 |
+
color: var(--muted-foreground);
|
| 1764 |
+
}
|
| 1765 |
+
.command-palette-result-sub {
|
| 1766 |
+
font-size: 12px;
|
| 1767 |
+
color: var(--muted-foreground);
|
| 1768 |
+
margin-top: 2px;
|
| 1769 |
+
}
|
| 1770 |
+
|
| 1771 |
+
/* ── Duplicate Alert Panel ── */
|
| 1772 |
+
.duplicate-alert {
|
| 1773 |
+
position: relative;
|
| 1774 |
+
overflow: hidden;
|
| 1775 |
+
border-color: rgba(245, 158, 11, 0.2) !important;
|
| 1776 |
+
background: linear-gradient(135deg, rgba(245, 158, 11, 0.03), rgba(245, 158, 11, 0.01)) !important;
|
| 1777 |
+
}
|
| 1778 |
+
.duplicate-alert::before {
|
| 1779 |
+
content: '';
|
| 1780 |
+
position: absolute;
|
| 1781 |
+
top: 0;
|
| 1782 |
+
left: 0;
|
| 1783 |
+
right: 0;
|
| 1784 |
+
height: 2px;
|
| 1785 |
+
background: linear-gradient(90deg, transparent, #F59E0B, transparent);
|
| 1786 |
+
}
|
| 1787 |
+
|
| 1788 |
+
/* ── Welcome Panel ── */
|
| 1789 |
+
.welcome-panel {
|
| 1790 |
+
position: relative;
|
| 1791 |
+
overflow: hidden;
|
| 1792 |
+
}
|
| 1793 |
+
.welcome-panel::before {
|
| 1794 |
+
content: '';
|
| 1795 |
+
position: absolute;
|
| 1796 |
+
top: -40px;
|
| 1797 |
+
right: -40px;
|
| 1798 |
+
width: 120px;
|
| 1799 |
+
height: 120px;
|
| 1800 |
+
background: radial-gradient(circle, rgba(245, 158, 11, 0.08), transparent 70%);
|
| 1801 |
+
border-radius: 50%;
|
| 1802 |
+
pointer-events: none;
|
| 1803 |
+
}
|
| 1804 |
+
.welcome-panel::after {
|
| 1805 |
+
content: '';
|
| 1806 |
+
position: absolute;
|
| 1807 |
+
bottom: -30px;
|
| 1808 |
+
left: -30px;
|
| 1809 |
+
width: 100px;
|
| 1810 |
+
height: 100px;
|
| 1811 |
+
background: radial-gradient(circle, rgba(20, 184, 166, 0.06), transparent 70%);
|
| 1812 |
+
border-radius: 50%;
|
| 1813 |
+
pointer-events: none;
|
| 1814 |
+
}
|
| 1815 |
+
.welcome-stat-btn {
|
| 1816 |
+
display: flex;
|
| 1817 |
+
align-items: center;
|
| 1818 |
+
gap: 8px;
|
| 1819 |
+
padding: 12px;
|
| 1820 |
+
border-radius: 10px;
|
| 1821 |
+
background: rgba(255, 255, 255, 0.03);
|
| 1822 |
+
border: 1px solid rgba(255, 255, 255, 0.04);
|
| 1823 |
+
text-align: left;
|
| 1824 |
+
cursor: pointer;
|
| 1825 |
+
transition: all 0.2s ease;
|
| 1826 |
+
color: inherit;
|
| 1827 |
+
width: 100%;
|
| 1828 |
+
font-family: inherit;
|
| 1829 |
+
}
|
| 1830 |
+
.welcome-stat-btn:hover {
|
| 1831 |
+
background: rgba(255, 255, 255, 0.06);
|
| 1832 |
+
border-color: rgba(255, 255, 255, 0.08);
|
| 1833 |
+
transform: translateY(-1px);
|
| 1834 |
+
}
|
| 1835 |
+
.welcome-stat-btn:active {
|
| 1836 |
+
transform: translateY(0);
|
| 1837 |
+
}
|
| 1838 |
+
.welcome-stat-icon {
|
| 1839 |
+
color: var(--muted-foreground);
|
| 1840 |
+
transition: color 0.2s ease;
|
| 1841 |
+
}
|
| 1842 |
+
.welcome-stat-btn:hover .welcome-stat-icon {
|
| 1843 |
+
color: #F59E0B;
|
| 1844 |
+
}
|
| 1845 |
+
|
| 1846 |
+
/* ── Settings Toggle Switch ── */
|
| 1847 |
+
.toggle-switch {
|
| 1848 |
+
position: relative;
|
| 1849 |
+
width: 44px;
|
| 1850 |
+
height: 24px;
|
| 1851 |
+
border-radius: 12px;
|
| 1852 |
+
background: #3f3f46;
|
| 1853 |
+
border: none;
|
| 1854 |
+
cursor: pointer;
|
| 1855 |
+
transition: background 0.2s ease;
|
| 1856 |
+
padding: 0;
|
| 1857 |
+
flex-shrink: 0;
|
| 1858 |
+
}
|
| 1859 |
+
.toggle-switch.active {
|
| 1860 |
+
background: #F59E0B;
|
| 1861 |
+
}
|
| 1862 |
+
.toggle-switch-knob {
|
| 1863 |
+
position: absolute;
|
| 1864 |
+
top: 2px;
|
| 1865 |
+
left: 2px;
|
| 1866 |
+
width: 20px;
|
| 1867 |
+
height: 20px;
|
| 1868 |
+
border-radius: 50%;
|
| 1869 |
+
background: white;
|
| 1870 |
+
transition: transform 0.2s ease;
|
| 1871 |
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
| 1872 |
+
}
|
| 1873 |
+
.toggle-switch.active .toggle-switch-knob {
|
| 1874 |
+
transform: translateX(20px);
|
| 1875 |
+
}
|
| 1876 |
+
|
| 1877 |
+
/* ── Card Depth Hover ── */
|
| 1878 |
+
.card-depth {
|
| 1879 |
+
transition: transform 0.25s ease, box-shadow 0.25s ease;
|
| 1880 |
+
}
|
| 1881 |
+
.card-depth:hover {
|
| 1882 |
+
transform: translateY(-2px);
|
| 1883 |
+
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.05);
|
| 1884 |
+
}
|
| 1885 |
+
|
| 1886 |
+
/* ── Enhanced Badge System ── */
|
| 1887 |
+
.badge-pill {
|
| 1888 |
+
display: inline-flex;
|
| 1889 |
+
align-items: center;
|
| 1890 |
+
gap: 4px;
|
| 1891 |
+
padding: 2px 10px;
|
| 1892 |
+
border-radius: 9999px;
|
| 1893 |
+
font-size: 11px;
|
| 1894 |
+
font-weight: 500;
|
| 1895 |
+
letter-spacing: 0.01em;
|
| 1896 |
+
transition: all 0.15s ease;
|
| 1897 |
+
}
|
| 1898 |
+
.badge-pill-dot {
|
| 1899 |
+
width: 6px;
|
| 1900 |
+
height: 6px;
|
| 1901 |
+
border-radius: 50%;
|
| 1902 |
+
flex-shrink: 0;
|
| 1903 |
+
}
|
| 1904 |
+
.badge-teal-enhanced {
|
| 1905 |
+
background: rgba(20, 184, 166, 0.1);
|
| 1906 |
+
color: #14B8A6;
|
| 1907 |
+
border: 1px solid rgba(20, 184, 166, 0.15);
|
| 1908 |
+
}
|
| 1909 |
+
.badge-amber-enhanced {
|
| 1910 |
+
background: rgba(245, 158, 11, 0.1);
|
| 1911 |
+
color: #F59E0B;
|
| 1912 |
+
border: 1px solid rgba(245, 158, 11, 0.15);
|
| 1913 |
+
}
|
| 1914 |
+
.badge-red-enhanced {
|
| 1915 |
+
background: rgba(239, 68, 68, 0.1);
|
| 1916 |
+
color: #EF4444;
|
| 1917 |
+
border: 1px solid rgba(239, 68, 68, 0.15);
|
| 1918 |
+
}
|
| 1919 |
+
.badge-green-enhanced {
|
| 1920 |
+
background: rgba(34, 197, 94, 0.1);
|
| 1921 |
+
color: #22C55E;
|
| 1922 |
+
border: 1px solid rgba(34, 197, 94, 0.15);
|
| 1923 |
+
}
|
| 1924 |
+
|
| 1925 |
+
/* ── Pulse Dot ── */
|
| 1926 |
+
.pulse-dot {
|
| 1927 |
+
position: relative;
|
| 1928 |
+
width: 8px;
|
| 1929 |
+
height: 8px;
|
| 1930 |
+
border-radius: 50%;
|
| 1931 |
+
background: #F59E0B;
|
| 1932 |
+
}
|
| 1933 |
+
.pulse-dot::after {
|
| 1934 |
+
content: '';
|
| 1935 |
+
position: absolute;
|
| 1936 |
+
inset: -3px;
|
| 1937 |
+
border-radius: 50%;
|
| 1938 |
+
border: 2px solid #F59E0B;
|
| 1939 |
+
animation: pulseDot 2s ease infinite;
|
| 1940 |
+
}
|
| 1941 |
+
@keyframes pulseDot {
|
| 1942 |
+
0% { transform: scale(0.8); opacity: 0.8; }
|
| 1943 |
+
50% { transform: scale(1.4); opacity: 0; }
|
| 1944 |
+
100% { transform: scale(0.8); opacity: 0; }
|
| 1945 |
+
}
|
| 1946 |
+
|
| 1947 |
+
/* ── Teal Gradient Text ── */
|
| 1948 |
+
.gradient-text-teal {
|
| 1949 |
+
background: linear-gradient(135deg, #14B8A6, #5EEAD4);
|
| 1950 |
+
-webkit-background-clip: text;
|
| 1951 |
+
-webkit-text-fill-color: transparent;
|
| 1952 |
+
background-clip: text;
|
| 1953 |
+
}
|
| 1954 |
+
|
| 1955 |
+
/* ── Refined Scrollbar ── */
|
| 1956 |
+
.scrollbar-thin::-webkit-scrollbar {
|
| 1957 |
+
width: 6px;
|
| 1958 |
+
height: 6px;
|
| 1959 |
+
}
|
| 1960 |
+
.scrollbar-thin::-webkit-scrollbar-track {
|
| 1961 |
+
background: transparent;
|
| 1962 |
+
}
|
| 1963 |
+
.scrollbar-thin::-webkit-scrollbar-thumb {
|
| 1964 |
+
background: rgba(255, 255, 255, 0.08);
|
| 1965 |
+
border-radius: 3px;
|
| 1966 |
+
}
|
| 1967 |
+
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
| 1968 |
+
background: rgba(255, 255, 255, 0.15);
|
| 1969 |
+
}
|
| 1970 |
+
|
| 1971 |
+
/* ── Glass Card Hover Glow ── */
|
| 1972 |
+
.glass-card-glow {
|
| 1973 |
+
position: relative;
|
| 1974 |
+
transition: all 0.3s ease;
|
| 1975 |
+
}
|
| 1976 |
+
.glass-card-glow::after {
|
| 1977 |
+
content: '';
|
| 1978 |
+
position: absolute;
|
| 1979 |
+
inset: -1px;
|
| 1980 |
+
border-radius: inherit;
|
| 1981 |
+
background: linear-gradient(135deg, rgba(245, 158, 11, 0.15), rgba(20, 184, 166, 0.1), transparent, transparent);
|
| 1982 |
+
opacity: 0;
|
| 1983 |
+
z-index: -1;
|
| 1984 |
+
transition: opacity 0.3s ease;
|
| 1985 |
+
filter: blur(8px);
|
| 1986 |
+
}
|
| 1987 |
+
.glass-card-glow:hover::after {
|
| 1988 |
+
opacity: 1;
|
| 1989 |
+
}
|
| 1990 |
+
|
| 1991 |
+
/* ── Light Mode: New Class Overrides ── */
|
| 1992 |
+
:root.light .command-palette {
|
| 1993 |
+
background: rgba(255, 255, 255, 0.95);
|
| 1994 |
+
border-color: rgba(0, 0, 0, 0.08);
|
| 1995 |
+
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.03);
|
| 1996 |
+
}
|
| 1997 |
+
:root.light .command-palette-input {
|
| 1998 |
+
border-bottom-color: rgba(0, 0, 0, 0.06);
|
| 1999 |
+
}
|
| 2000 |
+
:root.light .command-palette-item:hover {
|
| 2001 |
+
background: rgba(0, 0, 0, 0.04);
|
| 2002 |
+
}
|
| 2003 |
+
:root.light .duplicate-alert {
|
| 2004 |
+
background: linear-gradient(135deg, rgba(245, 158, 11, 0.05), rgba(245, 158, 11, 0.02)) !important;
|
| 2005 |
+
}
|
| 2006 |
+
:root.light .welcome-panel::before {
|
| 2007 |
+
background: radial-gradient(circle, rgba(245, 158, 11, 0.1), transparent 70%);
|
| 2008 |
+
}
|
| 2009 |
+
:root.light .welcome-panel::after {
|
| 2010 |
+
background: radial-gradient(circle, rgba(20, 184, 166, 0.08), transparent 70%);
|
| 2011 |
+
}
|
| 2012 |
+
:root.light .welcome-stat-btn {
|
| 2013 |
+
background: rgba(0, 0, 0, 0.02);
|
| 2014 |
+
border-color: rgba(0, 0, 0, 0.06);
|
| 2015 |
+
}
|
| 2016 |
+
:root.light .welcome-stat-btn:hover {
|
| 2017 |
+
background: rgba(0, 0, 0, 0.04);
|
| 2018 |
+
border-color: rgba(0, 0, 0, 0.1);
|
| 2019 |
+
}
|
| 2020 |
+
:root.light .card-depth:hover {
|
| 2021 |
+
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
| 2022 |
+
}
|
| 2023 |
+
:root.light .toggle-switch {
|
| 2024 |
+
background: #d4d4d8;
|
| 2025 |
+
}
|
| 2026 |
+
:root.light .scrollbar-thin::-webkit-scrollbar-thumb {
|
| 2027 |
+
background: rgba(0, 0, 0, 0.1);
|
| 2028 |
+
}
|
| 2029 |
+
:root.light .scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
| 2030 |
+
background: rgba(0, 0, 0, 0.2);
|
| 2031 |
+
}
|
| 2032 |
+
:root.light .sort-header-inactive {
|
| 2033 |
+
opacity: 0;
|
| 2034 |
+
}
|
| 2035 |
+
:root.light .sort-header:hover .sort-header-inactive {
|
| 2036 |
+
opacity: 0.2;
|
| 2037 |
+
}
|
| 2038 |
+
:root.light .glass-card-glow::after {
|
| 2039 |
+
background: linear-gradient(135deg, rgba(245, 158, 11, 0.2), rgba(20, 184, 166, 0.15), transparent, transparent);
|
| 2040 |
+
}
|
src/app/layout.tsx
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import { Geist, Geist_Mono } from "next/font/google";
|
| 3 |
+
import "./globals.css";
|
| 4 |
+
import { Toaster } from "sonner";
|
| 5 |
+
import { ThemeProvider } from 'next-themes';
|
| 6 |
+
|
| 7 |
+
const geistSans = Geist({
|
| 8 |
+
variable: "--font-geist-sans",
|
| 9 |
+
subsets: ["latin"],
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
const geistMono = Geist_Mono({
|
| 13 |
+
variable: "--font-geist-mono",
|
| 14 |
+
subsets: ["latin"],
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
export const metadata: Metadata = {
|
| 18 |
+
title: "OmniParse AI — Invoice Processing in Seconds",
|
| 19 |
+
description:
|
| 20 |
+
"AI-powered invoice processing. Upload PDFs or images and extract vendor, dates, amounts, and line items as structured data. Export to CSV, JSON, or connect via API.",
|
| 21 |
+
keywords: [
|
| 22 |
+
"invoice processing",
|
| 23 |
+
"OCR",
|
| 24 |
+
"AI extraction",
|
| 25 |
+
"automated accounting",
|
| 26 |
+
"document processing",
|
| 27 |
+
],
|
| 28 |
+
icons: {
|
| 29 |
+
icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%23F59E0B'/><path d='M8 10h6v2H10v3h4v2h-4v3h4v2H8V10zm10 0h2l2 4 2-4h2v12h-2.5v-7.5L20 19h-.2l-1.3-2.5V22H16V10z' fill='%2309090B'/></svg>",
|
| 30 |
+
},
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
export default function RootLayout({
|
| 34 |
+
children,
|
| 35 |
+
}: Readonly<{
|
| 36 |
+
children: React.ReactNode;
|
| 37 |
+
}>) {
|
| 38 |
+
return (
|
| 39 |
+
<html lang="en" suppressHydrationWarning className="dark">
|
| 40 |
+
<body
|
| 41 |
+
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground noise-overlay`}
|
| 42 |
+
>
|
| 43 |
+
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
|
| 44 |
+
{children}
|
| 45 |
+
</ThemeProvider>
|
| 46 |
+
<Toaster
|
| 47 |
+
position="bottom-right"
|
| 48 |
+
toastOptions={{
|
| 49 |
+
style: {
|
| 50 |
+
background: "#1C1C1F",
|
| 51 |
+
border: "1px solid rgba(255,255,255,0.08)",
|
| 52 |
+
color: "#FAFAFA",
|
| 53 |
+
},
|
| 54 |
+
}}
|
| 55 |
+
/>
|
| 56 |
+
</body>
|
| 57 |
+
</html>
|
| 58 |
+
);
|
| 59 |
+
}
|
src/app/page.tsx
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/components/ui/accordion.tsx
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
| 5 |
+
import { ChevronDownIcon } from "lucide-react"
|
| 6 |
+
|
| 7 |
+
import { cn } from "@/lib/utils"
|
| 8 |
+
|
| 9 |
+
function Accordion({
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
| 12 |
+
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function AccordionItem({
|
| 16 |
+
className,
|
| 17 |
+
...props
|
| 18 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
| 19 |
+
return (
|
| 20 |
+
<AccordionPrimitive.Item
|
| 21 |
+
data-slot="accordion-item"
|
| 22 |
+
className={cn("border-b last:border-b-0", className)}
|
| 23 |
+
{...props}
|
| 24 |
+
/>
|
| 25 |
+
)
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function AccordionTrigger({
|
| 29 |
+
className,
|
| 30 |
+
children,
|
| 31 |
+
...props
|
| 32 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
| 33 |
+
return (
|
| 34 |
+
<AccordionPrimitive.Header className="flex">
|
| 35 |
+
<AccordionPrimitive.Trigger
|
| 36 |
+
data-slot="accordion-trigger"
|
| 37 |
+
className={cn(
|
| 38 |
+
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
| 39 |
+
className
|
| 40 |
+
)}
|
| 41 |
+
{...props}
|
| 42 |
+
>
|
| 43 |
+
{children}
|
| 44 |
+
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
| 45 |
+
</AccordionPrimitive.Trigger>
|
| 46 |
+
</AccordionPrimitive.Header>
|
| 47 |
+
)
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function AccordionContent({
|
| 51 |
+
className,
|
| 52 |
+
children,
|
| 53 |
+
...props
|
| 54 |
+
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
| 55 |
+
return (
|
| 56 |
+
<AccordionPrimitive.Content
|
| 57 |
+
data-slot="accordion-content"
|
| 58 |
+
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
| 59 |
+
{...props}
|
| 60 |
+
>
|
| 61 |
+
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
| 62 |
+
</AccordionPrimitive.Content>
|
| 63 |
+
)
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
src/components/ui/alert-dialog.tsx
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
import { buttonVariants } from "@/components/ui/button"
|
| 8 |
+
|
| 9 |
+
function AlertDialog({
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
| 12 |
+
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function AlertDialogTrigger({
|
| 16 |
+
...props
|
| 17 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
| 18 |
+
return (
|
| 19 |
+
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
| 20 |
+
)
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function AlertDialogPortal({
|
| 24 |
+
...props
|
| 25 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
| 26 |
+
return (
|
| 27 |
+
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
| 28 |
+
)
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function AlertDialogOverlay({
|
| 32 |
+
className,
|
| 33 |
+
...props
|
| 34 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
| 35 |
+
return (
|
| 36 |
+
<AlertDialogPrimitive.Overlay
|
| 37 |
+
data-slot="alert-dialog-overlay"
|
| 38 |
+
className={cn(
|
| 39 |
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
| 40 |
+
className
|
| 41 |
+
)}
|
| 42 |
+
{...props}
|
| 43 |
+
/>
|
| 44 |
+
)
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function AlertDialogContent({
|
| 48 |
+
className,
|
| 49 |
+
...props
|
| 50 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
| 51 |
+
return (
|
| 52 |
+
<AlertDialogPortal>
|
| 53 |
+
<AlertDialogOverlay />
|
| 54 |
+
<AlertDialogPrimitive.Content
|
| 55 |
+
data-slot="alert-dialog-content"
|
| 56 |
+
className={cn(
|
| 57 |
+
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
| 58 |
+
className
|
| 59 |
+
)}
|
| 60 |
+
{...props}
|
| 61 |
+
/>
|
| 62 |
+
</AlertDialogPortal>
|
| 63 |
+
)
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
function AlertDialogHeader({
|
| 67 |
+
className,
|
| 68 |
+
...props
|
| 69 |
+
}: React.ComponentProps<"div">) {
|
| 70 |
+
return (
|
| 71 |
+
<div
|
| 72 |
+
data-slot="alert-dialog-header"
|
| 73 |
+
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
| 74 |
+
{...props}
|
| 75 |
+
/>
|
| 76 |
+
)
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
function AlertDialogFooter({
|
| 80 |
+
className,
|
| 81 |
+
...props
|
| 82 |
+
}: React.ComponentProps<"div">) {
|
| 83 |
+
return (
|
| 84 |
+
<div
|
| 85 |
+
data-slot="alert-dialog-footer"
|
| 86 |
+
className={cn(
|
| 87 |
+
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
| 88 |
+
className
|
| 89 |
+
)}
|
| 90 |
+
{...props}
|
| 91 |
+
/>
|
| 92 |
+
)
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
function AlertDialogTitle({
|
| 96 |
+
className,
|
| 97 |
+
...props
|
| 98 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
| 99 |
+
return (
|
| 100 |
+
<AlertDialogPrimitive.Title
|
| 101 |
+
data-slot="alert-dialog-title"
|
| 102 |
+
className={cn("text-lg font-semibold", className)}
|
| 103 |
+
{...props}
|
| 104 |
+
/>
|
| 105 |
+
)
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
function AlertDialogDescription({
|
| 109 |
+
className,
|
| 110 |
+
...props
|
| 111 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
| 112 |
+
return (
|
| 113 |
+
<AlertDialogPrimitive.Description
|
| 114 |
+
data-slot="alert-dialog-description"
|
| 115 |
+
className={cn("text-muted-foreground text-sm", className)}
|
| 116 |
+
{...props}
|
| 117 |
+
/>
|
| 118 |
+
)
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
function AlertDialogAction({
|
| 122 |
+
className,
|
| 123 |
+
...props
|
| 124 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
| 125 |
+
return (
|
| 126 |
+
<AlertDialogPrimitive.Action
|
| 127 |
+
className={cn(buttonVariants(), className)}
|
| 128 |
+
{...props}
|
| 129 |
+
/>
|
| 130 |
+
)
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
function AlertDialogCancel({
|
| 134 |
+
className,
|
| 135 |
+
...props
|
| 136 |
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
| 137 |
+
return (
|
| 138 |
+
<AlertDialogPrimitive.Cancel
|
| 139 |
+
className={cn(buttonVariants({ variant: "outline" }), className)}
|
| 140 |
+
{...props}
|
| 141 |
+
/>
|
| 142 |
+
)
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
export {
|
| 146 |
+
AlertDialog,
|
| 147 |
+
AlertDialogPortal,
|
| 148 |
+
AlertDialogOverlay,
|
| 149 |
+
AlertDialogTrigger,
|
| 150 |
+
AlertDialogContent,
|
| 151 |
+
AlertDialogHeader,
|
| 152 |
+
AlertDialogFooter,
|
| 153 |
+
AlertDialogTitle,
|
| 154 |
+
AlertDialogDescription,
|
| 155 |
+
AlertDialogAction,
|
| 156 |
+
AlertDialogCancel,
|
| 157 |
+
}
|
src/components/ui/alert.tsx
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
| 3 |
+
|
| 4 |
+
import { cn } from "@/lib/utils"
|
| 5 |
+
|
| 6 |
+
const alertVariants = cva(
|
| 7 |
+
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
| 8 |
+
{
|
| 9 |
+
variants: {
|
| 10 |
+
variant: {
|
| 11 |
+
default: "bg-card text-card-foreground",
|
| 12 |
+
destructive:
|
| 13 |
+
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
| 14 |
+
},
|
| 15 |
+
},
|
| 16 |
+
defaultVariants: {
|
| 17 |
+
variant: "default",
|
| 18 |
+
},
|
| 19 |
+
}
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
function Alert({
|
| 23 |
+
className,
|
| 24 |
+
variant,
|
| 25 |
+
...props
|
| 26 |
+
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
| 27 |
+
return (
|
| 28 |
+
<div
|
| 29 |
+
data-slot="alert"
|
| 30 |
+
role="alert"
|
| 31 |
+
className={cn(alertVariants({ variant }), className)}
|
| 32 |
+
{...props}
|
| 33 |
+
/>
|
| 34 |
+
)
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
| 38 |
+
return (
|
| 39 |
+
<div
|
| 40 |
+
data-slot="alert-title"
|
| 41 |
+
className={cn(
|
| 42 |
+
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
| 43 |
+
className
|
| 44 |
+
)}
|
| 45 |
+
{...props}
|
| 46 |
+
/>
|
| 47 |
+
)
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function AlertDescription({
|
| 51 |
+
className,
|
| 52 |
+
...props
|
| 53 |
+
}: React.ComponentProps<"div">) {
|
| 54 |
+
return (
|
| 55 |
+
<div
|
| 56 |
+
data-slot="alert-description"
|
| 57 |
+
className={cn(
|
| 58 |
+
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
| 59 |
+
className
|
| 60 |
+
)}
|
| 61 |
+
{...props}
|
| 62 |
+
/>
|
| 63 |
+
)
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export { Alert, AlertTitle, AlertDescription }
|
src/components/ui/aspect-ratio.tsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
| 4 |
+
|
| 5 |
+
function AspectRatio({
|
| 6 |
+
...props
|
| 7 |
+
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
| 8 |
+
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export { AspectRatio }
|
src/components/ui/avatar.tsx
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
function Avatar({
|
| 9 |
+
className,
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
| 12 |
+
return (
|
| 13 |
+
<AvatarPrimitive.Root
|
| 14 |
+
data-slot="avatar"
|
| 15 |
+
className={cn(
|
| 16 |
+
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
| 17 |
+
className
|
| 18 |
+
)}
|
| 19 |
+
{...props}
|
| 20 |
+
/>
|
| 21 |
+
)
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function AvatarImage({
|
| 25 |
+
className,
|
| 26 |
+
...props
|
| 27 |
+
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
| 28 |
+
return (
|
| 29 |
+
<AvatarPrimitive.Image
|
| 30 |
+
data-slot="avatar-image"
|
| 31 |
+
className={cn("aspect-square size-full", className)}
|
| 32 |
+
{...props}
|
| 33 |
+
/>
|
| 34 |
+
)
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function AvatarFallback({
|
| 38 |
+
className,
|
| 39 |
+
...props
|
| 40 |
+
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
| 41 |
+
return (
|
| 42 |
+
<AvatarPrimitive.Fallback
|
| 43 |
+
data-slot="avatar-fallback"
|
| 44 |
+
className={cn(
|
| 45 |
+
"bg-muted flex size-full items-center justify-center rounded-full",
|
| 46 |
+
className
|
| 47 |
+
)}
|
| 48 |
+
{...props}
|
| 49 |
+
/>
|
| 50 |
+
)
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export { Avatar, AvatarImage, AvatarFallback }
|
src/components/ui/badge.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { Slot } from "@radix-ui/react-slot"
|
| 3 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
| 4 |
+
|
| 5 |
+
import { cn } from "@/lib/utils"
|
| 6 |
+
|
| 7 |
+
const badgeVariants = cva(
|
| 8 |
+
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
| 9 |
+
{
|
| 10 |
+
variants: {
|
| 11 |
+
variant: {
|
| 12 |
+
default:
|
| 13 |
+
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
| 14 |
+
secondary:
|
| 15 |
+
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
| 16 |
+
destructive:
|
| 17 |
+
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
| 18 |
+
outline:
|
| 19 |
+
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
| 20 |
+
},
|
| 21 |
+
},
|
| 22 |
+
defaultVariants: {
|
| 23 |
+
variant: "default",
|
| 24 |
+
},
|
| 25 |
+
}
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
function Badge({
|
| 29 |
+
className,
|
| 30 |
+
variant,
|
| 31 |
+
asChild = false,
|
| 32 |
+
...props
|
| 33 |
+
}: React.ComponentProps<"span"> &
|
| 34 |
+
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
| 35 |
+
const Comp = asChild ? Slot : "span"
|
| 36 |
+
|
| 37 |
+
return (
|
| 38 |
+
<Comp
|
| 39 |
+
data-slot="badge"
|
| 40 |
+
className={cn(badgeVariants({ variant }), className)}
|
| 41 |
+
{...props}
|
| 42 |
+
/>
|
| 43 |
+
)
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export { Badge, badgeVariants }
|
src/components/ui/breadcrumb.tsx
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { Slot } from "@radix-ui/react-slot"
|
| 3 |
+
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
| 4 |
+
|
| 5 |
+
import { cn } from "@/lib/utils"
|
| 6 |
+
|
| 7 |
+
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
| 8 |
+
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
| 12 |
+
return (
|
| 13 |
+
<ol
|
| 14 |
+
data-slot="breadcrumb-list"
|
| 15 |
+
className={cn(
|
| 16 |
+
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
| 17 |
+
className
|
| 18 |
+
)}
|
| 19 |
+
{...props}
|
| 20 |
+
/>
|
| 21 |
+
)
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
| 25 |
+
return (
|
| 26 |
+
<li
|
| 27 |
+
data-slot="breadcrumb-item"
|
| 28 |
+
className={cn("inline-flex items-center gap-1.5", className)}
|
| 29 |
+
{...props}
|
| 30 |
+
/>
|
| 31 |
+
)
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function BreadcrumbLink({
|
| 35 |
+
asChild,
|
| 36 |
+
className,
|
| 37 |
+
...props
|
| 38 |
+
}: React.ComponentProps<"a"> & {
|
| 39 |
+
asChild?: boolean
|
| 40 |
+
}) {
|
| 41 |
+
const Comp = asChild ? Slot : "a"
|
| 42 |
+
|
| 43 |
+
return (
|
| 44 |
+
<Comp
|
| 45 |
+
data-slot="breadcrumb-link"
|
| 46 |
+
className={cn("hover:text-foreground transition-colors", className)}
|
| 47 |
+
{...props}
|
| 48 |
+
/>
|
| 49 |
+
)
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
| 53 |
+
return (
|
| 54 |
+
<span
|
| 55 |
+
data-slot="breadcrumb-page"
|
| 56 |
+
role="link"
|
| 57 |
+
aria-disabled="true"
|
| 58 |
+
aria-current="page"
|
| 59 |
+
className={cn("text-foreground font-normal", className)}
|
| 60 |
+
{...props}
|
| 61 |
+
/>
|
| 62 |
+
)
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function BreadcrumbSeparator({
|
| 66 |
+
children,
|
| 67 |
+
className,
|
| 68 |
+
...props
|
| 69 |
+
}: React.ComponentProps<"li">) {
|
| 70 |
+
return (
|
| 71 |
+
<li
|
| 72 |
+
data-slot="breadcrumb-separator"
|
| 73 |
+
role="presentation"
|
| 74 |
+
aria-hidden="true"
|
| 75 |
+
className={cn("[&>svg]:size-3.5", className)}
|
| 76 |
+
{...props}
|
| 77 |
+
>
|
| 78 |
+
{children ?? <ChevronRight />}
|
| 79 |
+
</li>
|
| 80 |
+
)
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function BreadcrumbEllipsis({
|
| 84 |
+
className,
|
| 85 |
+
...props
|
| 86 |
+
}: React.ComponentProps<"span">) {
|
| 87 |
+
return (
|
| 88 |
+
<span
|
| 89 |
+
data-slot="breadcrumb-ellipsis"
|
| 90 |
+
role="presentation"
|
| 91 |
+
aria-hidden="true"
|
| 92 |
+
className={cn("flex size-9 items-center justify-center", className)}
|
| 93 |
+
{...props}
|
| 94 |
+
>
|
| 95 |
+
<MoreHorizontal className="size-4" />
|
| 96 |
+
<span className="sr-only">More</span>
|
| 97 |
+
</span>
|
| 98 |
+
)
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
export {
|
| 102 |
+
Breadcrumb,
|
| 103 |
+
BreadcrumbList,
|
| 104 |
+
BreadcrumbItem,
|
| 105 |
+
BreadcrumbLink,
|
| 106 |
+
BreadcrumbPage,
|
| 107 |
+
BreadcrumbSeparator,
|
| 108 |
+
BreadcrumbEllipsis,
|
| 109 |
+
}
|
src/components/ui/button.tsx
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
import { Slot } from "@radix-ui/react-slot"
|
| 3 |
+
import { cva, type VariantProps } from "class-variance-authority"
|
| 4 |
+
|
| 5 |
+
import { cn } from "@/lib/utils"
|
| 6 |
+
|
| 7 |
+
const buttonVariants = cva(
|
| 8 |
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
| 9 |
+
{
|
| 10 |
+
variants: {
|
| 11 |
+
variant: {
|
| 12 |
+
default:
|
| 13 |
+
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
| 14 |
+
destructive:
|
| 15 |
+
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
| 16 |
+
outline:
|
| 17 |
+
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
| 18 |
+
secondary:
|
| 19 |
+
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
| 20 |
+
ghost:
|
| 21 |
+
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
| 22 |
+
link: "text-primary underline-offset-4 hover:underline",
|
| 23 |
+
},
|
| 24 |
+
size: {
|
| 25 |
+
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
| 26 |
+
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
| 27 |
+
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
| 28 |
+
icon: "size-9",
|
| 29 |
+
},
|
| 30 |
+
},
|
| 31 |
+
defaultVariants: {
|
| 32 |
+
variant: "default",
|
| 33 |
+
size: "default",
|
| 34 |
+
},
|
| 35 |
+
}
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
function Button({
|
| 39 |
+
className,
|
| 40 |
+
variant,
|
| 41 |
+
size,
|
| 42 |
+
asChild = false,
|
| 43 |
+
...props
|
| 44 |
+
}: React.ComponentProps<"button"> &
|
| 45 |
+
VariantProps<typeof buttonVariants> & {
|
| 46 |
+
asChild?: boolean
|
| 47 |
+
}) {
|
| 48 |
+
const Comp = asChild ? Slot : "button"
|
| 49 |
+
|
| 50 |
+
return (
|
| 51 |
+
<Comp
|
| 52 |
+
data-slot="button"
|
| 53 |
+
className={cn(buttonVariants({ variant, size, className }))}
|
| 54 |
+
{...props}
|
| 55 |
+
/>
|
| 56 |
+
)
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export { Button, buttonVariants }
|
src/components/ui/calendar.tsx
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import {
|
| 5 |
+
ChevronDownIcon,
|
| 6 |
+
ChevronLeftIcon,
|
| 7 |
+
ChevronRightIcon,
|
| 8 |
+
} from "lucide-react"
|
| 9 |
+
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
| 10 |
+
|
| 11 |
+
import { cn } from "@/lib/utils"
|
| 12 |
+
import { Button, buttonVariants } from "@/components/ui/button"
|
| 13 |
+
|
| 14 |
+
function Calendar({
|
| 15 |
+
className,
|
| 16 |
+
classNames,
|
| 17 |
+
showOutsideDays = true,
|
| 18 |
+
captionLayout = "label",
|
| 19 |
+
buttonVariant = "ghost",
|
| 20 |
+
formatters,
|
| 21 |
+
components,
|
| 22 |
+
...props
|
| 23 |
+
}: React.ComponentProps<typeof DayPicker> & {
|
| 24 |
+
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
| 25 |
+
}) {
|
| 26 |
+
const defaultClassNames = getDefaultClassNames()
|
| 27 |
+
|
| 28 |
+
return (
|
| 29 |
+
<DayPicker
|
| 30 |
+
showOutsideDays={showOutsideDays}
|
| 31 |
+
className={cn(
|
| 32 |
+
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
| 33 |
+
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
| 34 |
+
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
| 35 |
+
className
|
| 36 |
+
)}
|
| 37 |
+
captionLayout={captionLayout}
|
| 38 |
+
formatters={{
|
| 39 |
+
formatMonthDropdown: (date) =>
|
| 40 |
+
date.toLocaleString("default", { month: "short" }),
|
| 41 |
+
...formatters,
|
| 42 |
+
}}
|
| 43 |
+
classNames={{
|
| 44 |
+
root: cn("w-fit", defaultClassNames.root),
|
| 45 |
+
months: cn(
|
| 46 |
+
"flex gap-4 flex-col md:flex-row relative",
|
| 47 |
+
defaultClassNames.months
|
| 48 |
+
),
|
| 49 |
+
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
| 50 |
+
nav: cn(
|
| 51 |
+
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
| 52 |
+
defaultClassNames.nav
|
| 53 |
+
),
|
| 54 |
+
button_previous: cn(
|
| 55 |
+
buttonVariants({ variant: buttonVariant }),
|
| 56 |
+
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
| 57 |
+
defaultClassNames.button_previous
|
| 58 |
+
),
|
| 59 |
+
button_next: cn(
|
| 60 |
+
buttonVariants({ variant: buttonVariant }),
|
| 61 |
+
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
| 62 |
+
defaultClassNames.button_next
|
| 63 |
+
),
|
| 64 |
+
month_caption: cn(
|
| 65 |
+
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
| 66 |
+
defaultClassNames.month_caption
|
| 67 |
+
),
|
| 68 |
+
dropdowns: cn(
|
| 69 |
+
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
| 70 |
+
defaultClassNames.dropdowns
|
| 71 |
+
),
|
| 72 |
+
dropdown_root: cn(
|
| 73 |
+
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
| 74 |
+
defaultClassNames.dropdown_root
|
| 75 |
+
),
|
| 76 |
+
dropdown: cn(
|
| 77 |
+
"absolute bg-popover inset-0 opacity-0",
|
| 78 |
+
defaultClassNames.dropdown
|
| 79 |
+
),
|
| 80 |
+
caption_label: cn(
|
| 81 |
+
"select-none font-medium",
|
| 82 |
+
captionLayout === "label"
|
| 83 |
+
? "text-sm"
|
| 84 |
+
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
| 85 |
+
defaultClassNames.caption_label
|
| 86 |
+
),
|
| 87 |
+
table: "w-full border-collapse",
|
| 88 |
+
weekdays: cn("flex", defaultClassNames.weekdays),
|
| 89 |
+
weekday: cn(
|
| 90 |
+
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
| 91 |
+
defaultClassNames.weekday
|
| 92 |
+
),
|
| 93 |
+
week: cn("flex w-full mt-2", defaultClassNames.week),
|
| 94 |
+
week_number_header: cn(
|
| 95 |
+
"select-none w-(--cell-size)",
|
| 96 |
+
defaultClassNames.week_number_header
|
| 97 |
+
),
|
| 98 |
+
week_number: cn(
|
| 99 |
+
"text-[0.8rem] select-none text-muted-foreground",
|
| 100 |
+
defaultClassNames.week_number
|
| 101 |
+
),
|
| 102 |
+
day: cn(
|
| 103 |
+
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
| 104 |
+
defaultClassNames.day
|
| 105 |
+
),
|
| 106 |
+
range_start: cn(
|
| 107 |
+
"rounded-l-md bg-accent",
|
| 108 |
+
defaultClassNames.range_start
|
| 109 |
+
),
|
| 110 |
+
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
| 111 |
+
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
| 112 |
+
today: cn(
|
| 113 |
+
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
| 114 |
+
defaultClassNames.today
|
| 115 |
+
),
|
| 116 |
+
outside: cn(
|
| 117 |
+
"text-muted-foreground aria-selected:text-muted-foreground",
|
| 118 |
+
defaultClassNames.outside
|
| 119 |
+
),
|
| 120 |
+
disabled: cn(
|
| 121 |
+
"text-muted-foreground opacity-50",
|
| 122 |
+
defaultClassNames.disabled
|
| 123 |
+
),
|
| 124 |
+
hidden: cn("invisible", defaultClassNames.hidden),
|
| 125 |
+
...classNames,
|
| 126 |
+
}}
|
| 127 |
+
components={{
|
| 128 |
+
Root: ({ className, rootRef, ...props }) => {
|
| 129 |
+
return (
|
| 130 |
+
<div
|
| 131 |
+
data-slot="calendar"
|
| 132 |
+
ref={rootRef}
|
| 133 |
+
className={cn(className)}
|
| 134 |
+
{...props}
|
| 135 |
+
/>
|
| 136 |
+
)
|
| 137 |
+
},
|
| 138 |
+
Chevron: ({ className, orientation, ...props }) => {
|
| 139 |
+
if (orientation === "left") {
|
| 140 |
+
return (
|
| 141 |
+
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
| 142 |
+
)
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
if (orientation === "right") {
|
| 146 |
+
return (
|
| 147 |
+
<ChevronRightIcon
|
| 148 |
+
className={cn("size-4", className)}
|
| 149 |
+
{...props}
|
| 150 |
+
/>
|
| 151 |
+
)
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
return (
|
| 155 |
+
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
| 156 |
+
)
|
| 157 |
+
},
|
| 158 |
+
DayButton: CalendarDayButton,
|
| 159 |
+
WeekNumber: ({ children, ...props }) => {
|
| 160 |
+
return (
|
| 161 |
+
<td {...props}>
|
| 162 |
+
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
| 163 |
+
{children}
|
| 164 |
+
</div>
|
| 165 |
+
</td>
|
| 166 |
+
)
|
| 167 |
+
},
|
| 168 |
+
...components,
|
| 169 |
+
}}
|
| 170 |
+
{...props}
|
| 171 |
+
/>
|
| 172 |
+
)
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
function CalendarDayButton({
|
| 176 |
+
className,
|
| 177 |
+
day,
|
| 178 |
+
modifiers,
|
| 179 |
+
...props
|
| 180 |
+
}: React.ComponentProps<typeof DayButton>) {
|
| 181 |
+
const defaultClassNames = getDefaultClassNames()
|
| 182 |
+
|
| 183 |
+
const ref = React.useRef<HTMLButtonElement>(null)
|
| 184 |
+
React.useEffect(() => {
|
| 185 |
+
if (modifiers.focused) ref.current?.focus()
|
| 186 |
+
}, [modifiers.focused])
|
| 187 |
+
|
| 188 |
+
return (
|
| 189 |
+
<Button
|
| 190 |
+
ref={ref}
|
| 191 |
+
variant="ghost"
|
| 192 |
+
size="icon"
|
| 193 |
+
data-day={day.date.toLocaleDateString()}
|
| 194 |
+
data-selected-single={
|
| 195 |
+
modifiers.selected &&
|
| 196 |
+
!modifiers.range_start &&
|
| 197 |
+
!modifiers.range_end &&
|
| 198 |
+
!modifiers.range_middle
|
| 199 |
+
}
|
| 200 |
+
data-range-start={modifiers.range_start}
|
| 201 |
+
data-range-end={modifiers.range_end}
|
| 202 |
+
data-range-middle={modifiers.range_middle}
|
| 203 |
+
className={cn(
|
| 204 |
+
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
| 205 |
+
defaultClassNames.day,
|
| 206 |
+
className
|
| 207 |
+
)}
|
| 208 |
+
{...props}
|
| 209 |
+
/>
|
| 210 |
+
)
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
export { Calendar, CalendarDayButton }
|
src/components/ui/card.tsx
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
|
| 3 |
+
import { cn } from "@/lib/utils"
|
| 4 |
+
|
| 5 |
+
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
| 6 |
+
return (
|
| 7 |
+
<div
|
| 8 |
+
data-slot="card"
|
| 9 |
+
className={cn(
|
| 10 |
+
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
| 11 |
+
className
|
| 12 |
+
)}
|
| 13 |
+
{...props}
|
| 14 |
+
/>
|
| 15 |
+
)
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
| 19 |
+
return (
|
| 20 |
+
<div
|
| 21 |
+
data-slot="card-header"
|
| 22 |
+
className={cn(
|
| 23 |
+
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
| 24 |
+
className
|
| 25 |
+
)}
|
| 26 |
+
{...props}
|
| 27 |
+
/>
|
| 28 |
+
)
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
| 32 |
+
return (
|
| 33 |
+
<div
|
| 34 |
+
data-slot="card-title"
|
| 35 |
+
className={cn("leading-none font-semibold", className)}
|
| 36 |
+
{...props}
|
| 37 |
+
/>
|
| 38 |
+
)
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
| 42 |
+
return (
|
| 43 |
+
<div
|
| 44 |
+
data-slot="card-description"
|
| 45 |
+
className={cn("text-muted-foreground text-sm", className)}
|
| 46 |
+
{...props}
|
| 47 |
+
/>
|
| 48 |
+
)
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
| 52 |
+
return (
|
| 53 |
+
<div
|
| 54 |
+
data-slot="card-action"
|
| 55 |
+
className={cn(
|
| 56 |
+
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
| 57 |
+
className
|
| 58 |
+
)}
|
| 59 |
+
{...props}
|
| 60 |
+
/>
|
| 61 |
+
)
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
| 65 |
+
return (
|
| 66 |
+
<div
|
| 67 |
+
data-slot="card-content"
|
| 68 |
+
className={cn("px-6", className)}
|
| 69 |
+
{...props}
|
| 70 |
+
/>
|
| 71 |
+
)
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
| 75 |
+
return (
|
| 76 |
+
<div
|
| 77 |
+
data-slot="card-footer"
|
| 78 |
+
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
| 79 |
+
{...props}
|
| 80 |
+
/>
|
| 81 |
+
)
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export {
|
| 85 |
+
Card,
|
| 86 |
+
CardHeader,
|
| 87 |
+
CardFooter,
|
| 88 |
+
CardTitle,
|
| 89 |
+
CardAction,
|
| 90 |
+
CardDescription,
|
| 91 |
+
CardContent,
|
| 92 |
+
}
|
src/components/ui/carousel.tsx
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import useEmblaCarousel, {
|
| 5 |
+
type UseEmblaCarouselType,
|
| 6 |
+
} from "embla-carousel-react"
|
| 7 |
+
import { ArrowLeft, ArrowRight } from "lucide-react"
|
| 8 |
+
|
| 9 |
+
import { cn } from "@/lib/utils"
|
| 10 |
+
import { Button } from "@/components/ui/button"
|
| 11 |
+
|
| 12 |
+
type CarouselApi = UseEmblaCarouselType[1]
|
| 13 |
+
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
| 14 |
+
type CarouselOptions = UseCarouselParameters[0]
|
| 15 |
+
type CarouselPlugin = UseCarouselParameters[1]
|
| 16 |
+
|
| 17 |
+
type CarouselProps = {
|
| 18 |
+
opts?: CarouselOptions
|
| 19 |
+
plugins?: CarouselPlugin
|
| 20 |
+
orientation?: "horizontal" | "vertical"
|
| 21 |
+
setApi?: (api: CarouselApi) => void
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
type CarouselContextProps = {
|
| 25 |
+
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
| 26 |
+
api: ReturnType<typeof useEmblaCarousel>[1]
|
| 27 |
+
scrollPrev: () => void
|
| 28 |
+
scrollNext: () => void
|
| 29 |
+
canScrollPrev: boolean
|
| 30 |
+
canScrollNext: boolean
|
| 31 |
+
} & CarouselProps
|
| 32 |
+
|
| 33 |
+
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
| 34 |
+
|
| 35 |
+
function useCarousel() {
|
| 36 |
+
const context = React.useContext(CarouselContext)
|
| 37 |
+
|
| 38 |
+
if (!context) {
|
| 39 |
+
throw new Error("useCarousel must be used within a <Carousel />")
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
return context
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function Carousel({
|
| 46 |
+
orientation = "horizontal",
|
| 47 |
+
opts,
|
| 48 |
+
setApi,
|
| 49 |
+
plugins,
|
| 50 |
+
className,
|
| 51 |
+
children,
|
| 52 |
+
...props
|
| 53 |
+
}: React.ComponentProps<"div"> & CarouselProps) {
|
| 54 |
+
const [carouselRef, api] = useEmblaCarousel(
|
| 55 |
+
{
|
| 56 |
+
...opts,
|
| 57 |
+
axis: orientation === "horizontal" ? "x" : "y",
|
| 58 |
+
},
|
| 59 |
+
plugins
|
| 60 |
+
)
|
| 61 |
+
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
| 62 |
+
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
| 63 |
+
|
| 64 |
+
const onSelect = React.useCallback((api: CarouselApi) => {
|
| 65 |
+
if (!api) return
|
| 66 |
+
setCanScrollPrev(api.canScrollPrev())
|
| 67 |
+
setCanScrollNext(api.canScrollNext())
|
| 68 |
+
}, [])
|
| 69 |
+
|
| 70 |
+
const scrollPrev = React.useCallback(() => {
|
| 71 |
+
api?.scrollPrev()
|
| 72 |
+
}, [api])
|
| 73 |
+
|
| 74 |
+
const scrollNext = React.useCallback(() => {
|
| 75 |
+
api?.scrollNext()
|
| 76 |
+
}, [api])
|
| 77 |
+
|
| 78 |
+
const handleKeyDown = React.useCallback(
|
| 79 |
+
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
| 80 |
+
if (event.key === "ArrowLeft") {
|
| 81 |
+
event.preventDefault()
|
| 82 |
+
scrollPrev()
|
| 83 |
+
} else if (event.key === "ArrowRight") {
|
| 84 |
+
event.preventDefault()
|
| 85 |
+
scrollNext()
|
| 86 |
+
}
|
| 87 |
+
},
|
| 88 |
+
[scrollPrev, scrollNext]
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
React.useEffect(() => {
|
| 92 |
+
if (!api || !setApi) return
|
| 93 |
+
setApi(api)
|
| 94 |
+
}, [api, setApi])
|
| 95 |
+
|
| 96 |
+
React.useEffect(() => {
|
| 97 |
+
if (!api) return
|
| 98 |
+
onSelect(api)
|
| 99 |
+
api.on("reInit", onSelect)
|
| 100 |
+
api.on("select", onSelect)
|
| 101 |
+
|
| 102 |
+
return () => {
|
| 103 |
+
api?.off("select", onSelect)
|
| 104 |
+
}
|
| 105 |
+
}, [api, onSelect])
|
| 106 |
+
|
| 107 |
+
return (
|
| 108 |
+
<CarouselContext.Provider
|
| 109 |
+
value={{
|
| 110 |
+
carouselRef,
|
| 111 |
+
api: api,
|
| 112 |
+
opts,
|
| 113 |
+
orientation:
|
| 114 |
+
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
| 115 |
+
scrollPrev,
|
| 116 |
+
scrollNext,
|
| 117 |
+
canScrollPrev,
|
| 118 |
+
canScrollNext,
|
| 119 |
+
}}
|
| 120 |
+
>
|
| 121 |
+
<div
|
| 122 |
+
onKeyDownCapture={handleKeyDown}
|
| 123 |
+
className={cn("relative", className)}
|
| 124 |
+
role="region"
|
| 125 |
+
aria-roledescription="carousel"
|
| 126 |
+
data-slot="carousel"
|
| 127 |
+
{...props}
|
| 128 |
+
>
|
| 129 |
+
{children}
|
| 130 |
+
</div>
|
| 131 |
+
</CarouselContext.Provider>
|
| 132 |
+
)
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
| 136 |
+
const { carouselRef, orientation } = useCarousel()
|
| 137 |
+
|
| 138 |
+
return (
|
| 139 |
+
<div
|
| 140 |
+
ref={carouselRef}
|
| 141 |
+
className="overflow-hidden"
|
| 142 |
+
data-slot="carousel-content"
|
| 143 |
+
>
|
| 144 |
+
<div
|
| 145 |
+
className={cn(
|
| 146 |
+
"flex",
|
| 147 |
+
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
| 148 |
+
className
|
| 149 |
+
)}
|
| 150 |
+
{...props}
|
| 151 |
+
/>
|
| 152 |
+
</div>
|
| 153 |
+
)
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
| 157 |
+
const { orientation } = useCarousel()
|
| 158 |
+
|
| 159 |
+
return (
|
| 160 |
+
<div
|
| 161 |
+
role="group"
|
| 162 |
+
aria-roledescription="slide"
|
| 163 |
+
data-slot="carousel-item"
|
| 164 |
+
className={cn(
|
| 165 |
+
"min-w-0 shrink-0 grow-0 basis-full",
|
| 166 |
+
orientation === "horizontal" ? "pl-4" : "pt-4",
|
| 167 |
+
className
|
| 168 |
+
)}
|
| 169 |
+
{...props}
|
| 170 |
+
/>
|
| 171 |
+
)
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
function CarouselPrevious({
|
| 175 |
+
className,
|
| 176 |
+
variant = "outline",
|
| 177 |
+
size = "icon",
|
| 178 |
+
...props
|
| 179 |
+
}: React.ComponentProps<typeof Button>) {
|
| 180 |
+
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
| 181 |
+
|
| 182 |
+
return (
|
| 183 |
+
<Button
|
| 184 |
+
data-slot="carousel-previous"
|
| 185 |
+
variant={variant}
|
| 186 |
+
size={size}
|
| 187 |
+
className={cn(
|
| 188 |
+
"absolute size-8 rounded-full",
|
| 189 |
+
orientation === "horizontal"
|
| 190 |
+
? "top-1/2 -left-12 -translate-y-1/2"
|
| 191 |
+
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
| 192 |
+
className
|
| 193 |
+
)}
|
| 194 |
+
disabled={!canScrollPrev}
|
| 195 |
+
onClick={scrollPrev}
|
| 196 |
+
{...props}
|
| 197 |
+
>
|
| 198 |
+
<ArrowLeft />
|
| 199 |
+
<span className="sr-only">Previous slide</span>
|
| 200 |
+
</Button>
|
| 201 |
+
)
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
function CarouselNext({
|
| 205 |
+
className,
|
| 206 |
+
variant = "outline",
|
| 207 |
+
size = "icon",
|
| 208 |
+
...props
|
| 209 |
+
}: React.ComponentProps<typeof Button>) {
|
| 210 |
+
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
| 211 |
+
|
| 212 |
+
return (
|
| 213 |
+
<Button
|
| 214 |
+
data-slot="carousel-next"
|
| 215 |
+
variant={variant}
|
| 216 |
+
size={size}
|
| 217 |
+
className={cn(
|
| 218 |
+
"absolute size-8 rounded-full",
|
| 219 |
+
orientation === "horizontal"
|
| 220 |
+
? "top-1/2 -right-12 -translate-y-1/2"
|
| 221 |
+
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
| 222 |
+
className
|
| 223 |
+
)}
|
| 224 |
+
disabled={!canScrollNext}
|
| 225 |
+
onClick={scrollNext}
|
| 226 |
+
{...props}
|
| 227 |
+
>
|
| 228 |
+
<ArrowRight />
|
| 229 |
+
<span className="sr-only">Next slide</span>
|
| 230 |
+
</Button>
|
| 231 |
+
)
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
export {
|
| 235 |
+
type CarouselApi,
|
| 236 |
+
Carousel,
|
| 237 |
+
CarouselContent,
|
| 238 |
+
CarouselItem,
|
| 239 |
+
CarouselPrevious,
|
| 240 |
+
CarouselNext,
|
| 241 |
+
}
|
src/components/ui/chart.tsx
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as RechartsPrimitive from "recharts"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
// Format: { THEME_NAME: CSS_SELECTOR }
|
| 9 |
+
const THEMES = { light: "", dark: ".dark" } as const
|
| 10 |
+
|
| 11 |
+
export type ChartConfig = {
|
| 12 |
+
[k in string]: {
|
| 13 |
+
label?: React.ReactNode
|
| 14 |
+
icon?: React.ComponentType
|
| 15 |
+
} & (
|
| 16 |
+
| { color?: string; theme?: never }
|
| 17 |
+
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
| 18 |
+
)
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
type ChartContextProps = {
|
| 22 |
+
config: ChartConfig
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
| 26 |
+
|
| 27 |
+
function useChart() {
|
| 28 |
+
const context = React.useContext(ChartContext)
|
| 29 |
+
|
| 30 |
+
if (!context) {
|
| 31 |
+
throw new Error("useChart must be used within a <ChartContainer />")
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
return context
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function ChartContainer({
|
| 38 |
+
id,
|
| 39 |
+
className,
|
| 40 |
+
children,
|
| 41 |
+
config,
|
| 42 |
+
...props
|
| 43 |
+
}: React.ComponentProps<"div"> & {
|
| 44 |
+
config: ChartConfig
|
| 45 |
+
children: React.ComponentProps<
|
| 46 |
+
typeof RechartsPrimitive.ResponsiveContainer
|
| 47 |
+
>["children"]
|
| 48 |
+
}) {
|
| 49 |
+
const uniqueId = React.useId()
|
| 50 |
+
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<ChartContext.Provider value={{ config }}>
|
| 54 |
+
<div
|
| 55 |
+
data-slot="chart"
|
| 56 |
+
data-chart={chartId}
|
| 57 |
+
className={cn(
|
| 58 |
+
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
| 59 |
+
className
|
| 60 |
+
)}
|
| 61 |
+
{...props}
|
| 62 |
+
>
|
| 63 |
+
<ChartStyle id={chartId} config={config} />
|
| 64 |
+
<RechartsPrimitive.ResponsiveContainer>
|
| 65 |
+
{children}
|
| 66 |
+
</RechartsPrimitive.ResponsiveContainer>
|
| 67 |
+
</div>
|
| 68 |
+
</ChartContext.Provider>
|
| 69 |
+
)
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
| 73 |
+
const colorConfig = Object.entries(config).filter(
|
| 74 |
+
([, config]) => config.theme || config.color
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
if (!colorConfig.length) {
|
| 78 |
+
return null
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
return (
|
| 82 |
+
<style
|
| 83 |
+
dangerouslySetInnerHTML={{
|
| 84 |
+
__html: Object.entries(THEMES)
|
| 85 |
+
.map(
|
| 86 |
+
([theme, prefix]) => `
|
| 87 |
+
${prefix} [data-chart=${id}] {
|
| 88 |
+
${colorConfig
|
| 89 |
+
.map(([key, itemConfig]) => {
|
| 90 |
+
const color =
|
| 91 |
+
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
| 92 |
+
itemConfig.color
|
| 93 |
+
return color ? ` --color-${key}: ${color};` : null
|
| 94 |
+
})
|
| 95 |
+
.join("\n")}
|
| 96 |
+
}
|
| 97 |
+
`
|
| 98 |
+
)
|
| 99 |
+
.join("\n"),
|
| 100 |
+
}}
|
| 101 |
+
/>
|
| 102 |
+
)
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
const ChartTooltip = RechartsPrimitive.Tooltip
|
| 106 |
+
|
| 107 |
+
function ChartTooltipContent({
|
| 108 |
+
active,
|
| 109 |
+
payload,
|
| 110 |
+
className,
|
| 111 |
+
indicator = "dot",
|
| 112 |
+
hideLabel = false,
|
| 113 |
+
hideIndicator = false,
|
| 114 |
+
label,
|
| 115 |
+
labelFormatter,
|
| 116 |
+
labelClassName,
|
| 117 |
+
formatter,
|
| 118 |
+
color,
|
| 119 |
+
nameKey,
|
| 120 |
+
labelKey,
|
| 121 |
+
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
| 122 |
+
React.ComponentProps<"div"> & {
|
| 123 |
+
hideLabel?: boolean
|
| 124 |
+
hideIndicator?: boolean
|
| 125 |
+
indicator?: "line" | "dot" | "dashed"
|
| 126 |
+
nameKey?: string
|
| 127 |
+
labelKey?: string
|
| 128 |
+
}) {
|
| 129 |
+
const { config } = useChart()
|
| 130 |
+
|
| 131 |
+
const tooltipLabel = React.useMemo(() => {
|
| 132 |
+
if (hideLabel || !payload?.length) {
|
| 133 |
+
return null
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
const [item] = payload
|
| 137 |
+
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
| 138 |
+
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
| 139 |
+
const value =
|
| 140 |
+
!labelKey && typeof label === "string"
|
| 141 |
+
? config[label as keyof typeof config]?.label || label
|
| 142 |
+
: itemConfig?.label
|
| 143 |
+
|
| 144 |
+
if (labelFormatter) {
|
| 145 |
+
return (
|
| 146 |
+
<div className={cn("font-medium", labelClassName)}>
|
| 147 |
+
{labelFormatter(value, payload)}
|
| 148 |
+
</div>
|
| 149 |
+
)
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
if (!value) {
|
| 153 |
+
return null
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
| 157 |
+
}, [
|
| 158 |
+
label,
|
| 159 |
+
labelFormatter,
|
| 160 |
+
payload,
|
| 161 |
+
hideLabel,
|
| 162 |
+
labelClassName,
|
| 163 |
+
config,
|
| 164 |
+
labelKey,
|
| 165 |
+
])
|
| 166 |
+
|
| 167 |
+
if (!active || !payload?.length) {
|
| 168 |
+
return null
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
const nestLabel = payload.length === 1 && indicator !== "dot"
|
| 172 |
+
|
| 173 |
+
return (
|
| 174 |
+
<div
|
| 175 |
+
className={cn(
|
| 176 |
+
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
| 177 |
+
className
|
| 178 |
+
)}
|
| 179 |
+
>
|
| 180 |
+
{!nestLabel ? tooltipLabel : null}
|
| 181 |
+
<div className="grid gap-1.5">
|
| 182 |
+
{payload.map((item, index) => {
|
| 183 |
+
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
| 184 |
+
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
| 185 |
+
const indicatorColor = color || item.payload.fill || item.color
|
| 186 |
+
|
| 187 |
+
return (
|
| 188 |
+
<div
|
| 189 |
+
key={item.dataKey}
|
| 190 |
+
className={cn(
|
| 191 |
+
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
| 192 |
+
indicator === "dot" && "items-center"
|
| 193 |
+
)}
|
| 194 |
+
>
|
| 195 |
+
{formatter && item?.value !== undefined && item.name ? (
|
| 196 |
+
formatter(item.value, item.name, item, index, item.payload)
|
| 197 |
+
) : (
|
| 198 |
+
<>
|
| 199 |
+
{itemConfig?.icon ? (
|
| 200 |
+
<itemConfig.icon />
|
| 201 |
+
) : (
|
| 202 |
+
!hideIndicator && (
|
| 203 |
+
<div
|
| 204 |
+
className={cn(
|
| 205 |
+
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
| 206 |
+
{
|
| 207 |
+
"h-2.5 w-2.5": indicator === "dot",
|
| 208 |
+
"w-1": indicator === "line",
|
| 209 |
+
"w-0 border-[1.5px] border-dashed bg-transparent":
|
| 210 |
+
indicator === "dashed",
|
| 211 |
+
"my-0.5": nestLabel && indicator === "dashed",
|
| 212 |
+
}
|
| 213 |
+
)}
|
| 214 |
+
style={
|
| 215 |
+
{
|
| 216 |
+
"--color-bg": indicatorColor,
|
| 217 |
+
"--color-border": indicatorColor,
|
| 218 |
+
} as React.CSSProperties
|
| 219 |
+
}
|
| 220 |
+
/>
|
| 221 |
+
)
|
| 222 |
+
)}
|
| 223 |
+
<div
|
| 224 |
+
className={cn(
|
| 225 |
+
"flex flex-1 justify-between leading-none",
|
| 226 |
+
nestLabel ? "items-end" : "items-center"
|
| 227 |
+
)}
|
| 228 |
+
>
|
| 229 |
+
<div className="grid gap-1.5">
|
| 230 |
+
{nestLabel ? tooltipLabel : null}
|
| 231 |
+
<span className="text-muted-foreground">
|
| 232 |
+
{itemConfig?.label || item.name}
|
| 233 |
+
</span>
|
| 234 |
+
</div>
|
| 235 |
+
{item.value && (
|
| 236 |
+
<span className="text-foreground font-mono font-medium tabular-nums">
|
| 237 |
+
{item.value.toLocaleString()}
|
| 238 |
+
</span>
|
| 239 |
+
)}
|
| 240 |
+
</div>
|
| 241 |
+
</>
|
| 242 |
+
)}
|
| 243 |
+
</div>
|
| 244 |
+
)
|
| 245 |
+
})}
|
| 246 |
+
</div>
|
| 247 |
+
</div>
|
| 248 |
+
)
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
const ChartLegend = RechartsPrimitive.Legend
|
| 252 |
+
|
| 253 |
+
function ChartLegendContent({
|
| 254 |
+
className,
|
| 255 |
+
hideIcon = false,
|
| 256 |
+
payload,
|
| 257 |
+
verticalAlign = "bottom",
|
| 258 |
+
nameKey,
|
| 259 |
+
}: React.ComponentProps<"div"> &
|
| 260 |
+
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
| 261 |
+
hideIcon?: boolean
|
| 262 |
+
nameKey?: string
|
| 263 |
+
}) {
|
| 264 |
+
const { config } = useChart()
|
| 265 |
+
|
| 266 |
+
if (!payload?.length) {
|
| 267 |
+
return null
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
return (
|
| 271 |
+
<div
|
| 272 |
+
className={cn(
|
| 273 |
+
"flex items-center justify-center gap-4",
|
| 274 |
+
verticalAlign === "top" ? "pb-3" : "pt-3",
|
| 275 |
+
className
|
| 276 |
+
)}
|
| 277 |
+
>
|
| 278 |
+
{payload.map((item) => {
|
| 279 |
+
const key = `${nameKey || item.dataKey || "value"}`
|
| 280 |
+
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
| 281 |
+
|
| 282 |
+
return (
|
| 283 |
+
<div
|
| 284 |
+
key={item.value}
|
| 285 |
+
className={cn(
|
| 286 |
+
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
| 287 |
+
)}
|
| 288 |
+
>
|
| 289 |
+
{itemConfig?.icon && !hideIcon ? (
|
| 290 |
+
<itemConfig.icon />
|
| 291 |
+
) : (
|
| 292 |
+
<div
|
| 293 |
+
className="h-2 w-2 shrink-0 rounded-[2px]"
|
| 294 |
+
style={{
|
| 295 |
+
backgroundColor: item.color,
|
| 296 |
+
}}
|
| 297 |
+
/>
|
| 298 |
+
)}
|
| 299 |
+
{itemConfig?.label}
|
| 300 |
+
</div>
|
| 301 |
+
)
|
| 302 |
+
})}
|
| 303 |
+
</div>
|
| 304 |
+
)
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
// Helper to extract item config from a payload.
|
| 308 |
+
function getPayloadConfigFromPayload(
|
| 309 |
+
config: ChartConfig,
|
| 310 |
+
payload: unknown,
|
| 311 |
+
key: string
|
| 312 |
+
) {
|
| 313 |
+
if (typeof payload !== "object" || payload === null) {
|
| 314 |
+
return undefined
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
const payloadPayload =
|
| 318 |
+
"payload" in payload &&
|
| 319 |
+
typeof payload.payload === "object" &&
|
| 320 |
+
payload.payload !== null
|
| 321 |
+
? payload.payload
|
| 322 |
+
: undefined
|
| 323 |
+
|
| 324 |
+
let configLabelKey: string = key
|
| 325 |
+
|
| 326 |
+
if (
|
| 327 |
+
key in payload &&
|
| 328 |
+
typeof payload[key as keyof typeof payload] === "string"
|
| 329 |
+
) {
|
| 330 |
+
configLabelKey = payload[key as keyof typeof payload] as string
|
| 331 |
+
} else if (
|
| 332 |
+
payloadPayload &&
|
| 333 |
+
key in payloadPayload &&
|
| 334 |
+
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
| 335 |
+
) {
|
| 336 |
+
configLabelKey = payloadPayload[
|
| 337 |
+
key as keyof typeof payloadPayload
|
| 338 |
+
] as string
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
return configLabelKey in config
|
| 342 |
+
? config[configLabelKey]
|
| 343 |
+
: config[key as keyof typeof config]
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
export {
|
| 347 |
+
ChartContainer,
|
| 348 |
+
ChartTooltip,
|
| 349 |
+
ChartTooltipContent,
|
| 350 |
+
ChartLegend,
|
| 351 |
+
ChartLegendContent,
|
| 352 |
+
ChartStyle,
|
| 353 |
+
}
|
src/components/ui/checkbox.tsx
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
| 5 |
+
import { CheckIcon } from "lucide-react"
|
| 6 |
+
|
| 7 |
+
import { cn } from "@/lib/utils"
|
| 8 |
+
|
| 9 |
+
function Checkbox({
|
| 10 |
+
className,
|
| 11 |
+
...props
|
| 12 |
+
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
| 13 |
+
return (
|
| 14 |
+
<CheckboxPrimitive.Root
|
| 15 |
+
data-slot="checkbox"
|
| 16 |
+
className={cn(
|
| 17 |
+
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
| 18 |
+
className
|
| 19 |
+
)}
|
| 20 |
+
{...props}
|
| 21 |
+
>
|
| 22 |
+
<CheckboxPrimitive.Indicator
|
| 23 |
+
data-slot="checkbox-indicator"
|
| 24 |
+
className="flex items-center justify-center text-current transition-none"
|
| 25 |
+
>
|
| 26 |
+
<CheckIcon className="size-3.5" />
|
| 27 |
+
</CheckboxPrimitive.Indicator>
|
| 28 |
+
</CheckboxPrimitive.Root>
|
| 29 |
+
)
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export { Checkbox }
|
src/components/ui/collapsible.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
| 4 |
+
|
| 5 |
+
function Collapsible({
|
| 6 |
+
...props
|
| 7 |
+
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
| 8 |
+
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
function CollapsibleTrigger({
|
| 12 |
+
...props
|
| 13 |
+
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
| 14 |
+
return (
|
| 15 |
+
<CollapsiblePrimitive.CollapsibleTrigger
|
| 16 |
+
data-slot="collapsible-trigger"
|
| 17 |
+
{...props}
|
| 18 |
+
/>
|
| 19 |
+
)
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function CollapsibleContent({
|
| 23 |
+
...props
|
| 24 |
+
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
| 25 |
+
return (
|
| 26 |
+
<CollapsiblePrimitive.CollapsibleContent
|
| 27 |
+
data-slot="collapsible-content"
|
| 28 |
+
{...props}
|
| 29 |
+
/>
|
| 30 |
+
)
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
src/components/ui/command.tsx
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { Command as CommandPrimitive } from "cmdk"
|
| 5 |
+
import { SearchIcon } from "lucide-react"
|
| 6 |
+
|
| 7 |
+
import { cn } from "@/lib/utils"
|
| 8 |
+
import {
|
| 9 |
+
Dialog,
|
| 10 |
+
DialogContent,
|
| 11 |
+
DialogDescription,
|
| 12 |
+
DialogHeader,
|
| 13 |
+
DialogTitle,
|
| 14 |
+
} from "@/components/ui/dialog"
|
| 15 |
+
|
| 16 |
+
function Command({
|
| 17 |
+
className,
|
| 18 |
+
...props
|
| 19 |
+
}: React.ComponentProps<typeof CommandPrimitive>) {
|
| 20 |
+
return (
|
| 21 |
+
<CommandPrimitive
|
| 22 |
+
data-slot="command"
|
| 23 |
+
className={cn(
|
| 24 |
+
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
| 25 |
+
className
|
| 26 |
+
)}
|
| 27 |
+
{...props}
|
| 28 |
+
/>
|
| 29 |
+
)
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function CommandDialog({
|
| 33 |
+
title = "Command Palette",
|
| 34 |
+
description = "Search for a command to run...",
|
| 35 |
+
children,
|
| 36 |
+
className,
|
| 37 |
+
showCloseButton = true,
|
| 38 |
+
...props
|
| 39 |
+
}: React.ComponentProps<typeof Dialog> & {
|
| 40 |
+
title?: string
|
| 41 |
+
description?: string
|
| 42 |
+
className?: string
|
| 43 |
+
showCloseButton?: boolean
|
| 44 |
+
}) {
|
| 45 |
+
return (
|
| 46 |
+
<Dialog {...props}>
|
| 47 |
+
<DialogHeader className="sr-only">
|
| 48 |
+
<DialogTitle>{title}</DialogTitle>
|
| 49 |
+
<DialogDescription>{description}</DialogDescription>
|
| 50 |
+
</DialogHeader>
|
| 51 |
+
<DialogContent
|
| 52 |
+
className={cn("overflow-hidden p-0", className)}
|
| 53 |
+
showCloseButton={showCloseButton}
|
| 54 |
+
>
|
| 55 |
+
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
| 56 |
+
{children}
|
| 57 |
+
</Command>
|
| 58 |
+
</DialogContent>
|
| 59 |
+
</Dialog>
|
| 60 |
+
)
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function CommandInput({
|
| 64 |
+
className,
|
| 65 |
+
...props
|
| 66 |
+
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
| 67 |
+
return (
|
| 68 |
+
<div
|
| 69 |
+
data-slot="command-input-wrapper"
|
| 70 |
+
className="flex h-9 items-center gap-2 border-b px-3"
|
| 71 |
+
>
|
| 72 |
+
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
| 73 |
+
<CommandPrimitive.Input
|
| 74 |
+
data-slot="command-input"
|
| 75 |
+
className={cn(
|
| 76 |
+
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
| 77 |
+
className
|
| 78 |
+
)}
|
| 79 |
+
{...props}
|
| 80 |
+
/>
|
| 81 |
+
</div>
|
| 82 |
+
)
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function CommandList({
|
| 86 |
+
className,
|
| 87 |
+
...props
|
| 88 |
+
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
| 89 |
+
return (
|
| 90 |
+
<CommandPrimitive.List
|
| 91 |
+
data-slot="command-list"
|
| 92 |
+
className={cn(
|
| 93 |
+
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
| 94 |
+
className
|
| 95 |
+
)}
|
| 96 |
+
{...props}
|
| 97 |
+
/>
|
| 98 |
+
)
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function CommandEmpty({
|
| 102 |
+
...props
|
| 103 |
+
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
| 104 |
+
return (
|
| 105 |
+
<CommandPrimitive.Empty
|
| 106 |
+
data-slot="command-empty"
|
| 107 |
+
className="py-6 text-center text-sm"
|
| 108 |
+
{...props}
|
| 109 |
+
/>
|
| 110 |
+
)
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
function CommandGroup({
|
| 114 |
+
className,
|
| 115 |
+
...props
|
| 116 |
+
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
| 117 |
+
return (
|
| 118 |
+
<CommandPrimitive.Group
|
| 119 |
+
data-slot="command-group"
|
| 120 |
+
className={cn(
|
| 121 |
+
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
| 122 |
+
className
|
| 123 |
+
)}
|
| 124 |
+
{...props}
|
| 125 |
+
/>
|
| 126 |
+
)
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
function CommandSeparator({
|
| 130 |
+
className,
|
| 131 |
+
...props
|
| 132 |
+
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
| 133 |
+
return (
|
| 134 |
+
<CommandPrimitive.Separator
|
| 135 |
+
data-slot="command-separator"
|
| 136 |
+
className={cn("bg-border -mx-1 h-px", className)}
|
| 137 |
+
{...props}
|
| 138 |
+
/>
|
| 139 |
+
)
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
function CommandItem({
|
| 143 |
+
className,
|
| 144 |
+
...props
|
| 145 |
+
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
| 146 |
+
return (
|
| 147 |
+
<CommandPrimitive.Item
|
| 148 |
+
data-slot="command-item"
|
| 149 |
+
className={cn(
|
| 150 |
+
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 151 |
+
className
|
| 152 |
+
)}
|
| 153 |
+
{...props}
|
| 154 |
+
/>
|
| 155 |
+
)
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
function CommandShortcut({
|
| 159 |
+
className,
|
| 160 |
+
...props
|
| 161 |
+
}: React.ComponentProps<"span">) {
|
| 162 |
+
return (
|
| 163 |
+
<span
|
| 164 |
+
data-slot="command-shortcut"
|
| 165 |
+
className={cn(
|
| 166 |
+
"text-muted-foreground ml-auto text-xs tracking-widest",
|
| 167 |
+
className
|
| 168 |
+
)}
|
| 169 |
+
{...props}
|
| 170 |
+
/>
|
| 171 |
+
)
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
export {
|
| 175 |
+
Command,
|
| 176 |
+
CommandDialog,
|
| 177 |
+
CommandInput,
|
| 178 |
+
CommandList,
|
| 179 |
+
CommandEmpty,
|
| 180 |
+
CommandGroup,
|
| 181 |
+
CommandItem,
|
| 182 |
+
CommandShortcut,
|
| 183 |
+
CommandSeparator,
|
| 184 |
+
}
|
src/components/ui/context-menu.tsx
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
| 5 |
+
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
| 6 |
+
|
| 7 |
+
import { cn } from "@/lib/utils"
|
| 8 |
+
|
| 9 |
+
function ContextMenu({
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
| 12 |
+
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function ContextMenuTrigger({
|
| 16 |
+
...props
|
| 17 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
| 18 |
+
return (
|
| 19 |
+
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
| 20 |
+
)
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function ContextMenuGroup({
|
| 24 |
+
...props
|
| 25 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
| 26 |
+
return (
|
| 27 |
+
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
| 28 |
+
)
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function ContextMenuPortal({
|
| 32 |
+
...props
|
| 33 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
| 34 |
+
return (
|
| 35 |
+
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
| 36 |
+
)
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function ContextMenuSub({
|
| 40 |
+
...props
|
| 41 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
| 42 |
+
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function ContextMenuRadioGroup({
|
| 46 |
+
...props
|
| 47 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
| 48 |
+
return (
|
| 49 |
+
<ContextMenuPrimitive.RadioGroup
|
| 50 |
+
data-slot="context-menu-radio-group"
|
| 51 |
+
{...props}
|
| 52 |
+
/>
|
| 53 |
+
)
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function ContextMenuSubTrigger({
|
| 57 |
+
className,
|
| 58 |
+
inset,
|
| 59 |
+
children,
|
| 60 |
+
...props
|
| 61 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
| 62 |
+
inset?: boolean
|
| 63 |
+
}) {
|
| 64 |
+
return (
|
| 65 |
+
<ContextMenuPrimitive.SubTrigger
|
| 66 |
+
data-slot="context-menu-sub-trigger"
|
| 67 |
+
data-inset={inset}
|
| 68 |
+
className={cn(
|
| 69 |
+
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 70 |
+
className
|
| 71 |
+
)}
|
| 72 |
+
{...props}
|
| 73 |
+
>
|
| 74 |
+
{children}
|
| 75 |
+
<ChevronRightIcon className="ml-auto" />
|
| 76 |
+
</ContextMenuPrimitive.SubTrigger>
|
| 77 |
+
)
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function ContextMenuSubContent({
|
| 81 |
+
className,
|
| 82 |
+
...props
|
| 83 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
| 84 |
+
return (
|
| 85 |
+
<ContextMenuPrimitive.SubContent
|
| 86 |
+
data-slot="context-menu-sub-content"
|
| 87 |
+
className={cn(
|
| 88 |
+
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
| 89 |
+
className
|
| 90 |
+
)}
|
| 91 |
+
{...props}
|
| 92 |
+
/>
|
| 93 |
+
)
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function ContextMenuContent({
|
| 97 |
+
className,
|
| 98 |
+
...props
|
| 99 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
| 100 |
+
return (
|
| 101 |
+
<ContextMenuPrimitive.Portal>
|
| 102 |
+
<ContextMenuPrimitive.Content
|
| 103 |
+
data-slot="context-menu-content"
|
| 104 |
+
className={cn(
|
| 105 |
+
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
| 106 |
+
className
|
| 107 |
+
)}
|
| 108 |
+
{...props}
|
| 109 |
+
/>
|
| 110 |
+
</ContextMenuPrimitive.Portal>
|
| 111 |
+
)
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
function ContextMenuItem({
|
| 115 |
+
className,
|
| 116 |
+
inset,
|
| 117 |
+
variant = "default",
|
| 118 |
+
...props
|
| 119 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
| 120 |
+
inset?: boolean
|
| 121 |
+
variant?: "default" | "destructive"
|
| 122 |
+
}) {
|
| 123 |
+
return (
|
| 124 |
+
<ContextMenuPrimitive.Item
|
| 125 |
+
data-slot="context-menu-item"
|
| 126 |
+
data-inset={inset}
|
| 127 |
+
data-variant={variant}
|
| 128 |
+
className={cn(
|
| 129 |
+
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 130 |
+
className
|
| 131 |
+
)}
|
| 132 |
+
{...props}
|
| 133 |
+
/>
|
| 134 |
+
)
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
function ContextMenuCheckboxItem({
|
| 138 |
+
className,
|
| 139 |
+
children,
|
| 140 |
+
checked,
|
| 141 |
+
...props
|
| 142 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
| 143 |
+
return (
|
| 144 |
+
<ContextMenuPrimitive.CheckboxItem
|
| 145 |
+
data-slot="context-menu-checkbox-item"
|
| 146 |
+
className={cn(
|
| 147 |
+
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 148 |
+
className
|
| 149 |
+
)}
|
| 150 |
+
checked={checked}
|
| 151 |
+
{...props}
|
| 152 |
+
>
|
| 153 |
+
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
| 154 |
+
<ContextMenuPrimitive.ItemIndicator>
|
| 155 |
+
<CheckIcon className="size-4" />
|
| 156 |
+
</ContextMenuPrimitive.ItemIndicator>
|
| 157 |
+
</span>
|
| 158 |
+
{children}
|
| 159 |
+
</ContextMenuPrimitive.CheckboxItem>
|
| 160 |
+
)
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
function ContextMenuRadioItem({
|
| 164 |
+
className,
|
| 165 |
+
children,
|
| 166 |
+
...props
|
| 167 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
| 168 |
+
return (
|
| 169 |
+
<ContextMenuPrimitive.RadioItem
|
| 170 |
+
data-slot="context-menu-radio-item"
|
| 171 |
+
className={cn(
|
| 172 |
+
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 173 |
+
className
|
| 174 |
+
)}
|
| 175 |
+
{...props}
|
| 176 |
+
>
|
| 177 |
+
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
| 178 |
+
<ContextMenuPrimitive.ItemIndicator>
|
| 179 |
+
<CircleIcon className="size-2 fill-current" />
|
| 180 |
+
</ContextMenuPrimitive.ItemIndicator>
|
| 181 |
+
</span>
|
| 182 |
+
{children}
|
| 183 |
+
</ContextMenuPrimitive.RadioItem>
|
| 184 |
+
)
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
function ContextMenuLabel({
|
| 188 |
+
className,
|
| 189 |
+
inset,
|
| 190 |
+
...props
|
| 191 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
| 192 |
+
inset?: boolean
|
| 193 |
+
}) {
|
| 194 |
+
return (
|
| 195 |
+
<ContextMenuPrimitive.Label
|
| 196 |
+
data-slot="context-menu-label"
|
| 197 |
+
data-inset={inset}
|
| 198 |
+
className={cn(
|
| 199 |
+
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
| 200 |
+
className
|
| 201 |
+
)}
|
| 202 |
+
{...props}
|
| 203 |
+
/>
|
| 204 |
+
)
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
function ContextMenuSeparator({
|
| 208 |
+
className,
|
| 209 |
+
...props
|
| 210 |
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
| 211 |
+
return (
|
| 212 |
+
<ContextMenuPrimitive.Separator
|
| 213 |
+
data-slot="context-menu-separator"
|
| 214 |
+
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
| 215 |
+
{...props}
|
| 216 |
+
/>
|
| 217 |
+
)
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
function ContextMenuShortcut({
|
| 221 |
+
className,
|
| 222 |
+
...props
|
| 223 |
+
}: React.ComponentProps<"span">) {
|
| 224 |
+
return (
|
| 225 |
+
<span
|
| 226 |
+
data-slot="context-menu-shortcut"
|
| 227 |
+
className={cn(
|
| 228 |
+
"text-muted-foreground ml-auto text-xs tracking-widest",
|
| 229 |
+
className
|
| 230 |
+
)}
|
| 231 |
+
{...props}
|
| 232 |
+
/>
|
| 233 |
+
)
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
export {
|
| 237 |
+
ContextMenu,
|
| 238 |
+
ContextMenuTrigger,
|
| 239 |
+
ContextMenuContent,
|
| 240 |
+
ContextMenuItem,
|
| 241 |
+
ContextMenuCheckboxItem,
|
| 242 |
+
ContextMenuRadioItem,
|
| 243 |
+
ContextMenuLabel,
|
| 244 |
+
ContextMenuSeparator,
|
| 245 |
+
ContextMenuShortcut,
|
| 246 |
+
ContextMenuGroup,
|
| 247 |
+
ContextMenuPortal,
|
| 248 |
+
ContextMenuSub,
|
| 249 |
+
ContextMenuSubContent,
|
| 250 |
+
ContextMenuSubTrigger,
|
| 251 |
+
ContextMenuRadioGroup,
|
| 252 |
+
}
|
src/components/ui/dialog.tsx
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
| 5 |
+
import { XIcon } from "lucide-react"
|
| 6 |
+
|
| 7 |
+
import { cn } from "@/lib/utils"
|
| 8 |
+
|
| 9 |
+
function Dialog({
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
| 12 |
+
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function DialogTrigger({
|
| 16 |
+
...props
|
| 17 |
+
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
| 18 |
+
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function DialogPortal({
|
| 22 |
+
...props
|
| 23 |
+
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
| 24 |
+
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function DialogClose({
|
| 28 |
+
...props
|
| 29 |
+
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
| 30 |
+
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function DialogOverlay({
|
| 34 |
+
className,
|
| 35 |
+
...props
|
| 36 |
+
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
| 37 |
+
return (
|
| 38 |
+
<DialogPrimitive.Overlay
|
| 39 |
+
data-slot="dialog-overlay"
|
| 40 |
+
className={cn(
|
| 41 |
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
| 42 |
+
className
|
| 43 |
+
)}
|
| 44 |
+
{...props}
|
| 45 |
+
/>
|
| 46 |
+
)
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function DialogContent({
|
| 50 |
+
className,
|
| 51 |
+
children,
|
| 52 |
+
showCloseButton = true,
|
| 53 |
+
...props
|
| 54 |
+
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
| 55 |
+
showCloseButton?: boolean
|
| 56 |
+
}) {
|
| 57 |
+
return (
|
| 58 |
+
<DialogPortal data-slot="dialog-portal">
|
| 59 |
+
<DialogOverlay />
|
| 60 |
+
<DialogPrimitive.Content
|
| 61 |
+
data-slot="dialog-content"
|
| 62 |
+
className={cn(
|
| 63 |
+
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
| 64 |
+
className
|
| 65 |
+
)}
|
| 66 |
+
{...props}
|
| 67 |
+
>
|
| 68 |
+
{children}
|
| 69 |
+
{showCloseButton && (
|
| 70 |
+
<DialogPrimitive.Close
|
| 71 |
+
data-slot="dialog-close"
|
| 72 |
+
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
| 73 |
+
>
|
| 74 |
+
<XIcon />
|
| 75 |
+
<span className="sr-only">Close</span>
|
| 76 |
+
</DialogPrimitive.Close>
|
| 77 |
+
)}
|
| 78 |
+
</DialogPrimitive.Content>
|
| 79 |
+
</DialogPortal>
|
| 80 |
+
)
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
| 84 |
+
return (
|
| 85 |
+
<div
|
| 86 |
+
data-slot="dialog-header"
|
| 87 |
+
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
| 88 |
+
{...props}
|
| 89 |
+
/>
|
| 90 |
+
)
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
| 94 |
+
return (
|
| 95 |
+
<div
|
| 96 |
+
data-slot="dialog-footer"
|
| 97 |
+
className={cn(
|
| 98 |
+
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
| 99 |
+
className
|
| 100 |
+
)}
|
| 101 |
+
{...props}
|
| 102 |
+
/>
|
| 103 |
+
)
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
function DialogTitle({
|
| 107 |
+
className,
|
| 108 |
+
...props
|
| 109 |
+
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
| 110 |
+
return (
|
| 111 |
+
<DialogPrimitive.Title
|
| 112 |
+
data-slot="dialog-title"
|
| 113 |
+
className={cn("text-lg leading-none font-semibold", className)}
|
| 114 |
+
{...props}
|
| 115 |
+
/>
|
| 116 |
+
)
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
function DialogDescription({
|
| 120 |
+
className,
|
| 121 |
+
...props
|
| 122 |
+
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
| 123 |
+
return (
|
| 124 |
+
<DialogPrimitive.Description
|
| 125 |
+
data-slot="dialog-description"
|
| 126 |
+
className={cn("text-muted-foreground text-sm", className)}
|
| 127 |
+
{...props}
|
| 128 |
+
/>
|
| 129 |
+
)
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
export {
|
| 133 |
+
Dialog,
|
| 134 |
+
DialogClose,
|
| 135 |
+
DialogContent,
|
| 136 |
+
DialogDescription,
|
| 137 |
+
DialogFooter,
|
| 138 |
+
DialogHeader,
|
| 139 |
+
DialogOverlay,
|
| 140 |
+
DialogPortal,
|
| 141 |
+
DialogTitle,
|
| 142 |
+
DialogTrigger,
|
| 143 |
+
}
|
src/components/ui/drawer.tsx
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { Drawer as DrawerPrimitive } from "vaul"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
function Drawer({
|
| 9 |
+
...props
|
| 10 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
| 11 |
+
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
function DrawerTrigger({
|
| 15 |
+
...props
|
| 16 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
| 17 |
+
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function DrawerPortal({
|
| 21 |
+
...props
|
| 22 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
| 23 |
+
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
function DrawerClose({
|
| 27 |
+
...props
|
| 28 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
| 29 |
+
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function DrawerOverlay({
|
| 33 |
+
className,
|
| 34 |
+
...props
|
| 35 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
| 36 |
+
return (
|
| 37 |
+
<DrawerPrimitive.Overlay
|
| 38 |
+
data-slot="drawer-overlay"
|
| 39 |
+
className={cn(
|
| 40 |
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
| 41 |
+
className
|
| 42 |
+
)}
|
| 43 |
+
{...props}
|
| 44 |
+
/>
|
| 45 |
+
)
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function DrawerContent({
|
| 49 |
+
className,
|
| 50 |
+
children,
|
| 51 |
+
...props
|
| 52 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
| 53 |
+
return (
|
| 54 |
+
<DrawerPortal data-slot="drawer-portal">
|
| 55 |
+
<DrawerOverlay />
|
| 56 |
+
<DrawerPrimitive.Content
|
| 57 |
+
data-slot="drawer-content"
|
| 58 |
+
className={cn(
|
| 59 |
+
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
| 60 |
+
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
| 61 |
+
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
| 62 |
+
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
| 63 |
+
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
| 64 |
+
className
|
| 65 |
+
)}
|
| 66 |
+
{...props}
|
| 67 |
+
>
|
| 68 |
+
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
| 69 |
+
{children}
|
| 70 |
+
</DrawerPrimitive.Content>
|
| 71 |
+
</DrawerPortal>
|
| 72 |
+
)
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
| 76 |
+
return (
|
| 77 |
+
<div
|
| 78 |
+
data-slot="drawer-header"
|
| 79 |
+
className={cn(
|
| 80 |
+
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
| 81 |
+
className
|
| 82 |
+
)}
|
| 83 |
+
{...props}
|
| 84 |
+
/>
|
| 85 |
+
)
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
| 89 |
+
return (
|
| 90 |
+
<div
|
| 91 |
+
data-slot="drawer-footer"
|
| 92 |
+
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
| 93 |
+
{...props}
|
| 94 |
+
/>
|
| 95 |
+
)
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
function DrawerTitle({
|
| 99 |
+
className,
|
| 100 |
+
...props
|
| 101 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
| 102 |
+
return (
|
| 103 |
+
<DrawerPrimitive.Title
|
| 104 |
+
data-slot="drawer-title"
|
| 105 |
+
className={cn("text-foreground font-semibold", className)}
|
| 106 |
+
{...props}
|
| 107 |
+
/>
|
| 108 |
+
)
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function DrawerDescription({
|
| 112 |
+
className,
|
| 113 |
+
...props
|
| 114 |
+
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
| 115 |
+
return (
|
| 116 |
+
<DrawerPrimitive.Description
|
| 117 |
+
data-slot="drawer-description"
|
| 118 |
+
className={cn("text-muted-foreground text-sm", className)}
|
| 119 |
+
{...props}
|
| 120 |
+
/>
|
| 121 |
+
)
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
export {
|
| 125 |
+
Drawer,
|
| 126 |
+
DrawerPortal,
|
| 127 |
+
DrawerOverlay,
|
| 128 |
+
DrawerTrigger,
|
| 129 |
+
DrawerClose,
|
| 130 |
+
DrawerContent,
|
| 131 |
+
DrawerHeader,
|
| 132 |
+
DrawerFooter,
|
| 133 |
+
DrawerTitle,
|
| 134 |
+
DrawerDescription,
|
| 135 |
+
}
|
src/components/ui/dropdown-menu.tsx
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
| 5 |
+
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
| 6 |
+
|
| 7 |
+
import { cn } from "@/lib/utils"
|
| 8 |
+
|
| 9 |
+
function DropdownMenu({
|
| 10 |
+
...props
|
| 11 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
| 12 |
+
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function DropdownMenuPortal({
|
| 16 |
+
...props
|
| 17 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
| 18 |
+
return (
|
| 19 |
+
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
| 20 |
+
)
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function DropdownMenuTrigger({
|
| 24 |
+
...props
|
| 25 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
| 26 |
+
return (
|
| 27 |
+
<DropdownMenuPrimitive.Trigger
|
| 28 |
+
data-slot="dropdown-menu-trigger"
|
| 29 |
+
{...props}
|
| 30 |
+
/>
|
| 31 |
+
)
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function DropdownMenuContent({
|
| 35 |
+
className,
|
| 36 |
+
sideOffset = 4,
|
| 37 |
+
...props
|
| 38 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
| 39 |
+
return (
|
| 40 |
+
<DropdownMenuPrimitive.Portal>
|
| 41 |
+
<DropdownMenuPrimitive.Content
|
| 42 |
+
data-slot="dropdown-menu-content"
|
| 43 |
+
sideOffset={sideOffset}
|
| 44 |
+
className={cn(
|
| 45 |
+
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
| 46 |
+
className
|
| 47 |
+
)}
|
| 48 |
+
{...props}
|
| 49 |
+
/>
|
| 50 |
+
</DropdownMenuPrimitive.Portal>
|
| 51 |
+
)
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
function DropdownMenuGroup({
|
| 55 |
+
...props
|
| 56 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
| 57 |
+
return (
|
| 58 |
+
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
| 59 |
+
)
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
function DropdownMenuItem({
|
| 63 |
+
className,
|
| 64 |
+
inset,
|
| 65 |
+
variant = "default",
|
| 66 |
+
...props
|
| 67 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
| 68 |
+
inset?: boolean
|
| 69 |
+
variant?: "default" | "destructive"
|
| 70 |
+
}) {
|
| 71 |
+
return (
|
| 72 |
+
<DropdownMenuPrimitive.Item
|
| 73 |
+
data-slot="dropdown-menu-item"
|
| 74 |
+
data-inset={inset}
|
| 75 |
+
data-variant={variant}
|
| 76 |
+
className={cn(
|
| 77 |
+
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 78 |
+
className
|
| 79 |
+
)}
|
| 80 |
+
{...props}
|
| 81 |
+
/>
|
| 82 |
+
)
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function DropdownMenuCheckboxItem({
|
| 86 |
+
className,
|
| 87 |
+
children,
|
| 88 |
+
checked,
|
| 89 |
+
...props
|
| 90 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
| 91 |
+
return (
|
| 92 |
+
<DropdownMenuPrimitive.CheckboxItem
|
| 93 |
+
data-slot="dropdown-menu-checkbox-item"
|
| 94 |
+
className={cn(
|
| 95 |
+
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 96 |
+
className
|
| 97 |
+
)}
|
| 98 |
+
checked={checked}
|
| 99 |
+
{...props}
|
| 100 |
+
>
|
| 101 |
+
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
| 102 |
+
<DropdownMenuPrimitive.ItemIndicator>
|
| 103 |
+
<CheckIcon className="size-4" />
|
| 104 |
+
</DropdownMenuPrimitive.ItemIndicator>
|
| 105 |
+
</span>
|
| 106 |
+
{children}
|
| 107 |
+
</DropdownMenuPrimitive.CheckboxItem>
|
| 108 |
+
)
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function DropdownMenuRadioGroup({
|
| 112 |
+
...props
|
| 113 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
| 114 |
+
return (
|
| 115 |
+
<DropdownMenuPrimitive.RadioGroup
|
| 116 |
+
data-slot="dropdown-menu-radio-group"
|
| 117 |
+
{...props}
|
| 118 |
+
/>
|
| 119 |
+
)
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
function DropdownMenuRadioItem({
|
| 123 |
+
className,
|
| 124 |
+
children,
|
| 125 |
+
...props
|
| 126 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
| 127 |
+
return (
|
| 128 |
+
<DropdownMenuPrimitive.RadioItem
|
| 129 |
+
data-slot="dropdown-menu-radio-item"
|
| 130 |
+
className={cn(
|
| 131 |
+
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
| 132 |
+
className
|
| 133 |
+
)}
|
| 134 |
+
{...props}
|
| 135 |
+
>
|
| 136 |
+
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
| 137 |
+
<DropdownMenuPrimitive.ItemIndicator>
|
| 138 |
+
<CircleIcon className="size-2 fill-current" />
|
| 139 |
+
</DropdownMenuPrimitive.ItemIndicator>
|
| 140 |
+
</span>
|
| 141 |
+
{children}
|
| 142 |
+
</DropdownMenuPrimitive.RadioItem>
|
| 143 |
+
)
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
function DropdownMenuLabel({
|
| 147 |
+
className,
|
| 148 |
+
inset,
|
| 149 |
+
...props
|
| 150 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
| 151 |
+
inset?: boolean
|
| 152 |
+
}) {
|
| 153 |
+
return (
|
| 154 |
+
<DropdownMenuPrimitive.Label
|
| 155 |
+
data-slot="dropdown-menu-label"
|
| 156 |
+
data-inset={inset}
|
| 157 |
+
className={cn(
|
| 158 |
+
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
| 159 |
+
className
|
| 160 |
+
)}
|
| 161 |
+
{...props}
|
| 162 |
+
/>
|
| 163 |
+
)
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
function DropdownMenuSeparator({
|
| 167 |
+
className,
|
| 168 |
+
...props
|
| 169 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
| 170 |
+
return (
|
| 171 |
+
<DropdownMenuPrimitive.Separator
|
| 172 |
+
data-slot="dropdown-menu-separator"
|
| 173 |
+
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
| 174 |
+
{...props}
|
| 175 |
+
/>
|
| 176 |
+
)
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
function DropdownMenuShortcut({
|
| 180 |
+
className,
|
| 181 |
+
...props
|
| 182 |
+
}: React.ComponentProps<"span">) {
|
| 183 |
+
return (
|
| 184 |
+
<span
|
| 185 |
+
data-slot="dropdown-menu-shortcut"
|
| 186 |
+
className={cn(
|
| 187 |
+
"text-muted-foreground ml-auto text-xs tracking-widest",
|
| 188 |
+
className
|
| 189 |
+
)}
|
| 190 |
+
{...props}
|
| 191 |
+
/>
|
| 192 |
+
)
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
function DropdownMenuSub({
|
| 196 |
+
...props
|
| 197 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
| 198 |
+
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
function DropdownMenuSubTrigger({
|
| 202 |
+
className,
|
| 203 |
+
inset,
|
| 204 |
+
children,
|
| 205 |
+
...props
|
| 206 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
| 207 |
+
inset?: boolean
|
| 208 |
+
}) {
|
| 209 |
+
return (
|
| 210 |
+
<DropdownMenuPrimitive.SubTrigger
|
| 211 |
+
data-slot="dropdown-menu-sub-trigger"
|
| 212 |
+
data-inset={inset}
|
| 213 |
+
className={cn(
|
| 214 |
+
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
| 215 |
+
className
|
| 216 |
+
)}
|
| 217 |
+
{...props}
|
| 218 |
+
>
|
| 219 |
+
{children}
|
| 220 |
+
<ChevronRightIcon className="ml-auto size-4" />
|
| 221 |
+
</DropdownMenuPrimitive.SubTrigger>
|
| 222 |
+
)
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
function DropdownMenuSubContent({
|
| 226 |
+
className,
|
| 227 |
+
...props
|
| 228 |
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
| 229 |
+
return (
|
| 230 |
+
<DropdownMenuPrimitive.SubContent
|
| 231 |
+
data-slot="dropdown-menu-sub-content"
|
| 232 |
+
className={cn(
|
| 233 |
+
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
| 234 |
+
className
|
| 235 |
+
)}
|
| 236 |
+
{...props}
|
| 237 |
+
/>
|
| 238 |
+
)
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
export {
|
| 242 |
+
DropdownMenu,
|
| 243 |
+
DropdownMenuPortal,
|
| 244 |
+
DropdownMenuTrigger,
|
| 245 |
+
DropdownMenuContent,
|
| 246 |
+
DropdownMenuGroup,
|
| 247 |
+
DropdownMenuLabel,
|
| 248 |
+
DropdownMenuItem,
|
| 249 |
+
DropdownMenuCheckboxItem,
|
| 250 |
+
DropdownMenuRadioGroup,
|
| 251 |
+
DropdownMenuRadioItem,
|
| 252 |
+
DropdownMenuSeparator,
|
| 253 |
+
DropdownMenuShortcut,
|
| 254 |
+
DropdownMenuSub,
|
| 255 |
+
DropdownMenuSubTrigger,
|
| 256 |
+
DropdownMenuSubContent,
|
| 257 |
+
}
|
src/components/ui/form.tsx
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as LabelPrimitive from "@radix-ui/react-label"
|
| 5 |
+
import { Slot } from "@radix-ui/react-slot"
|
| 6 |
+
import {
|
| 7 |
+
Controller,
|
| 8 |
+
FormProvider,
|
| 9 |
+
useFormContext,
|
| 10 |
+
useFormState,
|
| 11 |
+
type ControllerProps,
|
| 12 |
+
type FieldPath,
|
| 13 |
+
type FieldValues,
|
| 14 |
+
} from "react-hook-form"
|
| 15 |
+
|
| 16 |
+
import { cn } from "@/lib/utils"
|
| 17 |
+
import { Label } from "@/components/ui/label"
|
| 18 |
+
|
| 19 |
+
const Form = FormProvider
|
| 20 |
+
|
| 21 |
+
type FormFieldContextValue<
|
| 22 |
+
TFieldValues extends FieldValues = FieldValues,
|
| 23 |
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
| 24 |
+
> = {
|
| 25 |
+
name: TName
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
| 29 |
+
{} as FormFieldContextValue
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
const FormField = <
|
| 33 |
+
TFieldValues extends FieldValues = FieldValues,
|
| 34 |
+
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
| 35 |
+
>({
|
| 36 |
+
...props
|
| 37 |
+
}: ControllerProps<TFieldValues, TName>) => {
|
| 38 |
+
return (
|
| 39 |
+
<FormFieldContext.Provider value={{ name: props.name }}>
|
| 40 |
+
<Controller {...props} />
|
| 41 |
+
</FormFieldContext.Provider>
|
| 42 |
+
)
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const useFormField = () => {
|
| 46 |
+
const fieldContext = React.useContext(FormFieldContext)
|
| 47 |
+
const itemContext = React.useContext(FormItemContext)
|
| 48 |
+
const { getFieldState } = useFormContext()
|
| 49 |
+
const formState = useFormState({ name: fieldContext.name })
|
| 50 |
+
const fieldState = getFieldState(fieldContext.name, formState)
|
| 51 |
+
|
| 52 |
+
if (!fieldContext) {
|
| 53 |
+
throw new Error("useFormField should be used within <FormField>")
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const { id } = itemContext
|
| 57 |
+
|
| 58 |
+
return {
|
| 59 |
+
id,
|
| 60 |
+
name: fieldContext.name,
|
| 61 |
+
formItemId: `${id}-form-item`,
|
| 62 |
+
formDescriptionId: `${id}-form-item-description`,
|
| 63 |
+
formMessageId: `${id}-form-item-message`,
|
| 64 |
+
...fieldState,
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
type FormItemContextValue = {
|
| 69 |
+
id: string
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
const FormItemContext = React.createContext<FormItemContextValue>(
|
| 73 |
+
{} as FormItemContextValue
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
| 77 |
+
const id = React.useId()
|
| 78 |
+
|
| 79 |
+
return (
|
| 80 |
+
<FormItemContext.Provider value={{ id }}>
|
| 81 |
+
<div
|
| 82 |
+
data-slot="form-item"
|
| 83 |
+
className={cn("grid gap-2", className)}
|
| 84 |
+
{...props}
|
| 85 |
+
/>
|
| 86 |
+
</FormItemContext.Provider>
|
| 87 |
+
)
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
function FormLabel({
|
| 91 |
+
className,
|
| 92 |
+
...props
|
| 93 |
+
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
| 94 |
+
const { error, formItemId } = useFormField()
|
| 95 |
+
|
| 96 |
+
return (
|
| 97 |
+
<Label
|
| 98 |
+
data-slot="form-label"
|
| 99 |
+
data-error={!!error}
|
| 100 |
+
className={cn("data-[error=true]:text-destructive", className)}
|
| 101 |
+
htmlFor={formItemId}
|
| 102 |
+
{...props}
|
| 103 |
+
/>
|
| 104 |
+
)
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
| 108 |
+
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
| 109 |
+
|
| 110 |
+
return (
|
| 111 |
+
<Slot
|
| 112 |
+
data-slot="form-control"
|
| 113 |
+
id={formItemId}
|
| 114 |
+
aria-describedby={
|
| 115 |
+
!error
|
| 116 |
+
? `${formDescriptionId}`
|
| 117 |
+
: `${formDescriptionId} ${formMessageId}`
|
| 118 |
+
}
|
| 119 |
+
aria-invalid={!!error}
|
| 120 |
+
{...props}
|
| 121 |
+
/>
|
| 122 |
+
)
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
| 126 |
+
const { formDescriptionId } = useFormField()
|
| 127 |
+
|
| 128 |
+
return (
|
| 129 |
+
<p
|
| 130 |
+
data-slot="form-description"
|
| 131 |
+
id={formDescriptionId}
|
| 132 |
+
className={cn("text-muted-foreground text-sm", className)}
|
| 133 |
+
{...props}
|
| 134 |
+
/>
|
| 135 |
+
)
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
| 139 |
+
const { error, formMessageId } = useFormField()
|
| 140 |
+
const body = error ? String(error?.message ?? "") : props.children
|
| 141 |
+
|
| 142 |
+
if (!body) {
|
| 143 |
+
return null
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
return (
|
| 147 |
+
<p
|
| 148 |
+
data-slot="form-message"
|
| 149 |
+
id={formMessageId}
|
| 150 |
+
className={cn("text-destructive text-sm", className)}
|
| 151 |
+
{...props}
|
| 152 |
+
>
|
| 153 |
+
{body}
|
| 154 |
+
</p>
|
| 155 |
+
)
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
export {
|
| 159 |
+
useFormField,
|
| 160 |
+
Form,
|
| 161 |
+
FormItem,
|
| 162 |
+
FormLabel,
|
| 163 |
+
FormControl,
|
| 164 |
+
FormDescription,
|
| 165 |
+
FormMessage,
|
| 166 |
+
FormField,
|
| 167 |
+
}
|
src/components/ui/hover-card.tsx
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
| 5 |
+
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
function HoverCard({
|
| 9 |
+
...props
|
| 10 |
+
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
| 11 |
+
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
function HoverCardTrigger({
|
| 15 |
+
...props
|
| 16 |
+
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
| 17 |
+
return (
|
| 18 |
+
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
| 19 |
+
)
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function HoverCardContent({
|
| 23 |
+
className,
|
| 24 |
+
align = "center",
|
| 25 |
+
sideOffset = 4,
|
| 26 |
+
...props
|
| 27 |
+
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
| 28 |
+
return (
|
| 29 |
+
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
| 30 |
+
<HoverCardPrimitive.Content
|
| 31 |
+
data-slot="hover-card-content"
|
| 32 |
+
align={align}
|
| 33 |
+
sideOffset={sideOffset}
|
| 34 |
+
className={cn(
|
| 35 |
+
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
| 36 |
+
className
|
| 37 |
+
)}
|
| 38 |
+
{...props}
|
| 39 |
+
/>
|
| 40 |
+
</HoverCardPrimitive.Portal>
|
| 41 |
+
)
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
src/components/ui/input-otp.tsx
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { OTPInput, OTPInputContext } from "input-otp"
|
| 5 |
+
import { MinusIcon } from "lucide-react"
|
| 6 |
+
|
| 7 |
+
import { cn } from "@/lib/utils"
|
| 8 |
+
|
| 9 |
+
function InputOTP({
|
| 10 |
+
className,
|
| 11 |
+
containerClassName,
|
| 12 |
+
...props
|
| 13 |
+
}: React.ComponentProps<typeof OTPInput> & {
|
| 14 |
+
containerClassName?: string
|
| 15 |
+
}) {
|
| 16 |
+
return (
|
| 17 |
+
<OTPInput
|
| 18 |
+
data-slot="input-otp"
|
| 19 |
+
containerClassName={cn(
|
| 20 |
+
"flex items-center gap-2 has-disabled:opacity-50",
|
| 21 |
+
containerClassName
|
| 22 |
+
)}
|
| 23 |
+
className={cn("disabled:cursor-not-allowed", className)}
|
| 24 |
+
{...props}
|
| 25 |
+
/>
|
| 26 |
+
)
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
| 30 |
+
return (
|
| 31 |
+
<div
|
| 32 |
+
data-slot="input-otp-group"
|
| 33 |
+
className={cn("flex items-center", className)}
|
| 34 |
+
{...props}
|
| 35 |
+
/>
|
| 36 |
+
)
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function InputOTPSlot({
|
| 40 |
+
index,
|
| 41 |
+
className,
|
| 42 |
+
...props
|
| 43 |
+
}: React.ComponentProps<"div"> & {
|
| 44 |
+
index: number
|
| 45 |
+
}) {
|
| 46 |
+
const inputOTPContext = React.useContext(OTPInputContext)
|
| 47 |
+
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
|
| 48 |
+
|
| 49 |
+
return (
|
| 50 |
+
<div
|
| 51 |
+
data-slot="input-otp-slot"
|
| 52 |
+
data-active={isActive}
|
| 53 |
+
className={cn(
|
| 54 |
+
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
| 55 |
+
className
|
| 56 |
+
)}
|
| 57 |
+
{...props}
|
| 58 |
+
>
|
| 59 |
+
{char}
|
| 60 |
+
{hasFakeCaret && (
|
| 61 |
+
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
| 62 |
+
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
| 63 |
+
</div>
|
| 64 |
+
)}
|
| 65 |
+
</div>
|
| 66 |
+
)
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
| 70 |
+
return (
|
| 71 |
+
<div data-slot="input-otp-separator" role="separator" {...props}>
|
| 72 |
+
<MinusIcon />
|
| 73 |
+
</div>
|
| 74 |
+
)
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
src/components/ui/input.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from "react"
|
| 2 |
+
|
| 3 |
+
import { cn } from "@/lib/utils"
|
| 4 |
+
|
| 5 |
+
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
| 6 |
+
return (
|
| 7 |
+
<input
|
| 8 |
+
type={type}
|
| 9 |
+
data-slot="input"
|
| 10 |
+
className={cn(
|
| 11 |
+
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
| 12 |
+
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
| 13 |
+
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
| 14 |
+
className
|
| 15 |
+
)}
|
| 16 |
+
{...props}
|
| 17 |
+
/>
|
| 18 |
+
)
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export { Input }
|