diff --git a/.gitattributes b/.gitattributes
index a6344aac8c09253b3b630fb776ae94478aa0275b..ad7d34416243f1f802deac1c692fb1344ee0579f 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
*.zip filter=lfs diff=lfs merge=lfs -text
*.zst filter=lfs diff=lfs merge=lfs -text
*tfevents* filter=lfs diff=lfs merge=lfs -text
+src/app/favicon.ico filter=lfs diff=lfs merge=lfs -text
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..8accfe901c77d34e08fb2f73f8daf8a2aa43431a
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,50 @@
+# AmaniQuery Frontend - Hugging Face Spaces Dockerfile
+# Next.js frontend
+
+# Build stage
+FROM node:20-alpine AS builder
+
+WORKDIR /app
+
+# Environment for build
+ENV NEXT_TELEMETRY_DISABLED=1
+ENV NODE_ENV=production
+
+# Copy package files
+COPY package*.json ./
+
+# Install dependencies
+RUN npm ci
+
+# Copy source
+COPY . .
+
+# Build Next.js app
+RUN npm run build
+
+# Production stage
+FROM node:20-alpine AS runner
+
+WORKDIR /app
+
+ENV NODE_ENV=production
+ENV NEXT_TELEMETRY_DISABLED=1
+ENV PORT=3000
+
+# Create non-root user
+RUN addgroup --system --gid 1001 nodejs
+RUN adduser --system --uid 1001 nextjs
+
+# Copy built assets
+COPY --from=builder /app/public ./public
+COPY --from=builder /app/.next/standalone ./
+COPY --from=builder /app/.next/static ./.next/static
+
+USER nextjs
+
+# HF Spaces PORT
+EXPOSE 3000
+
+ENV HOSTNAME="0.0.0.0"
+
+CMD ["node", "server.js"]
diff --git a/README.md b/README.md
index 51bb185d2b087c58ea3196ea2f73f502dcbdddd3..886595b554482811b88fcfb5b01fbc316316c28e 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,26 @@
----
-title: Amaniquery Frontend
-emoji: 📈
-colorFrom: yellow
-colorTo: indigo
-sdk: docker
-pinned: false
----
-
-Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
+---
+title: AmaniQuery
+emoji: 🔍
+colorFrom: blue
+colorTo: green
+sdk: docker
+app_port: 3000
+pinned: true
+---
+
+# AmaniQuery Frontend
+
+AI-Powered Legal & Policy Research Assistant for Kenya.
+
+## Features
+
+- 🔍 Natural language search for Kenyan laws and bills
+- 💬 Chat with AI about legal documents
+- 🎤 Voice interface for queries
+- 📊 Research reports and analysis
+- 🔐 User authentication and saved chats
+
+## API Backend
+
+This frontend connects to:
+- Backend API: `https://amaniquery-backend.hf.space`
diff --git a/components.json b/components.json
new file mode 100644
index 0000000000000000000000000000000000000000..29c1451ab999d6b57b733ac568a34a9c644b1b6c
--- /dev/null
+++ b/components.json
@@ -0,0 +1,22 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.js",
+ "css": "src/app/globals.css",
+ "baseColor": "zinc",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "registries": {}
+}
diff --git a/jsconfig.json b/jsconfig.json
new file mode 100644
index 0000000000000000000000000000000000000000..c57898bca818b9e56e74baf10bd73b29ec99ee2a
--- /dev/null
+++ b/jsconfig.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src/**/*"]
+}
+
diff --git a/next.config.ts b/next.config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5e62c91dd1feafed0db67e04f15e7866e48e0f64
--- /dev/null
+++ b/next.config.ts
@@ -0,0 +1,18 @@
+import type { NextConfig } from "next";
+
+
+const nextConfig: NextConfig = {
+ output: 'standalone',
+ images: {
+ remotePatterns: [
+ {
+ protocol: 'https',
+ hostname: 'res.cloudinary.com',
+ pathname: '/**',
+ },
+ ],
+ },
+
+};
+
+export default nextConfig;
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000000000000000000000000000000000000..843e1fce2ed0972748311924d29c45e29a38b19b
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,9110 @@
+{
+ "name": "frontend",
+ "version": "0.3.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "frontend",
+ "version": "0.3.0",
+ "dependencies": {
+ "@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-label": "^2.1.8",
+ "@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-progress": "^1.1.8",
+ "@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-switch": "^1.2.6",
+ "@radix-ui/react-tabs": "^1.1.13",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@tailwindcss/postcss": "^4.1.17",
+ "@upstash/redis": "^1.35.7",
+ "@vercel/analytics": "^1.6.1",
+ "@vercel/speed-insights": "^1.2.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "framer-motion": "^12.23.26",
+ "livekit-client": "^2.16.0",
+ "livekit-server-sdk": "^2.14.2",
+ "lucide-react": "^0.554.0",
+ "next": "16.0.10",
+ "next-themes": "^0.4.6",
+ "react": "19.2.3",
+ "react-diff-viewer-continued": "^3.4.0",
+ "react-dom": "19.2.3",
+ "react-markdown": "^10.1.0",
+ "rehype-highlight": "^7.0.2",
+ "rehype-raw": "^7.0.0",
+ "remark-gfm": "^4.0.1",
+ "sonner": "^2.0.7",
+ "tailwind-merge": "^3.4.0",
+ "tailwindcss": "^4.1.17"
+ },
+ "devDependencies": {
+ "@types/node": "^22",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "autoprefixer": "^10.4.20",
+ "baseline-browser-mapping": "^2.9.4",
+ "eslint": "^9",
+ "eslint-config-next": "16.0.10",
+ "postcss": "^8.4.49",
+ "tailwindcss": "^4.1.17",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "^5"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.27.1",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.27.1",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.28.5",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.28.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/generator": "^7.28.5",
+ "@babel/helper-compilation-targets": "^7.27.2",
+ "@babel/helper-module-transforms": "^7.28.3",
+ "@babel/helpers": "^7.28.4",
+ "@babel/parser": "^7.28.5",
+ "@babel/template": "^7.27.2",
+ "@babel/traverse": "^7.28.5",
+ "@babel/types": "^7.28.5",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.28.5",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.28.5",
+ "@babel/types": "^7.28.5",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.27.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.27.2",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.27.1",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.27.1",
+ "@babel/types": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.27.1",
+ "@babel/traverse": "^7.28.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.28.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.27.2",
+ "@babel/types": "^7.28.4"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.28.5",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.5"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.28.4",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.27.2",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/parser": "^7.27.2",
+ "@babel/types": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.28.5",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/generator": "^7.28.5",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.28.5",
+ "@babel/template": "^7.27.2",
+ "@babel/types": "^7.28.5",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.28.5",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bufbuild/protobuf": {
+ "version": "1.10.1",
+ "license": "(Apache-2.0 AND BSD-3-Clause)"
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.7.1",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.1.0",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.7.0",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.1.0",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emotion/babel-plugin": {
+ "version": "11.13.5",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.16.7",
+ "@babel/runtime": "^7.18.3",
+ "@emotion/hash": "^0.9.2",
+ "@emotion/memoize": "^0.9.0",
+ "@emotion/serialize": "^1.3.3",
+ "babel-plugin-macros": "^3.1.0",
+ "convert-source-map": "^1.5.0",
+ "escape-string-regexp": "^4.0.0",
+ "find-root": "^1.1.0",
+ "source-map": "^0.5.7",
+ "stylis": "4.2.0"
+ }
+ },
+ "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": {
+ "version": "1.9.0",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/cache": {
+ "version": "11.14.0",
+ "license": "MIT",
+ "dependencies": {
+ "@emotion/memoize": "^0.9.0",
+ "@emotion/sheet": "^1.4.0",
+ "@emotion/utils": "^1.4.2",
+ "@emotion/weak-memoize": "^0.4.0",
+ "stylis": "4.2.0"
+ }
+ },
+ "node_modules/@emotion/css": {
+ "version": "11.13.5",
+ "license": "MIT",
+ "dependencies": {
+ "@emotion/babel-plugin": "^11.13.5",
+ "@emotion/cache": "^11.13.5",
+ "@emotion/serialize": "^1.3.3",
+ "@emotion/sheet": "^1.4.0",
+ "@emotion/utils": "^1.4.2"
+ }
+ },
+ "node_modules/@emotion/hash": {
+ "version": "0.9.2",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/memoize": {
+ "version": "0.9.0",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/serialize": {
+ "version": "1.3.3",
+ "license": "MIT",
+ "dependencies": {
+ "@emotion/hash": "^0.9.2",
+ "@emotion/memoize": "^0.9.0",
+ "@emotion/unitless": "^0.10.0",
+ "@emotion/utils": "^1.4.2",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@emotion/sheet": {
+ "version": "1.4.0",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/unitless": {
+ "version": "0.10.0",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/utils": {
+ "version": "1.4.2",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/weak-memoize": {
+ "version": "0.4.0",
+ "license": "MIT"
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.1",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.7",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.2"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.2",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.3.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.39.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.7",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.4.1",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@floating-ui/core": {
+ "version": "1.7.3",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.10"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.4",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.7.3",
+ "@floating-ui/utils": "^0.2.10"
+ }
+ },
+ "node_modules/@floating-ui/react-dom": {
+ "version": "2.1.6",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.7.4"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.10",
+ "license": "MIT"
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.1",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.7",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.1",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.0.0",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@livekit/mutex": {
+ "version": "1.1.1",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@livekit/protocol": {
+ "version": "1.42.2",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@bufbuild/protobuf": "^1.10.0"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "0.2.12",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.4.3",
+ "@emnapi/runtime": "^1.4.3",
+ "@tybys/wasm-util": "^0.10.0"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "16.0.10",
+ "license": "MIT"
+ },
+ "node_modules/@next/eslint-plugin-next": {
+ "version": "16.0.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-glob": "3.3.1"
+ }
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "16.0.10",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.10.tgz",
+ "integrity": "sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "16.0.10",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.10.tgz",
+ "integrity": "sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "16.0.10",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.10.tgz",
+ "integrity": "sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "16.0.10",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.10.tgz",
+ "integrity": "sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "16.0.10",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.10.tgz",
+ "integrity": "sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "16.0.10",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.10.tgz",
+ "integrity": "sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "16.0.10",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.10.tgz",
+ "integrity": "sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "16.0.10",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nolyfill/is-core-module": {
+ "version": "1.0.39",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.4.0"
+ }
+ },
+ "node_modules/@radix-ui/number": {
+ "version": "1.1.1",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/primitive": {
+ "version": "1.1.3",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-arrow": {
+ "version": "1.1.7",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-checkbox": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
+ "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collection": {
+ "version": "1.1.7",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context": {
+ "version": "1.1.2",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog": {
+ "version": "1.1.15",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-direction": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.1.11",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-escape-keydown": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dropdown-menu": {
+ "version": "2.1.16",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-menu": "2.1.16",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-guards": {
+ "version": "1.1.3",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-scope": {
+ "version": "1.1.7",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-id": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-label": {
+ "version": "2.1.8",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.4",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu": {
+ "version": "2.1.16",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.11",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popover": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
+ "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popper": {
+ "version": "1.2.8",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.0.0",
+ "@radix-ui/react-arrow": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-use-rect": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1",
+ "@radix-ui/rect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-portal": {
+ "version": "1.1.9",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-presence": {
+ "version": "1.1.5",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-progress": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.8.tgz",
+ "integrity": "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-context": "1.1.3",
+ "@radix-ui/react-primitive": "2.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz",
+ "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
+ "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-roving-focus": {
+ "version": "1.1.11",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-scroll-area": {
+ "version": "1.2.10",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.1",
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-select": {
+ "version": "2.2.6",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.1",
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-visually-hidden": "1.2.3",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slot": {
+ "version": "1.2.4",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-switch": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
+ "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tabs": {
+ "version": "1.1.13",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.11",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
+ "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-visually-hidden": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.2.2",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-effect-event": "0.0.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-effect-event": {
+ "version": "0.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-escape-keydown": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-previous": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-rect": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/rect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-size": {
+ "version": "1.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-visually-hidden": {
+ "version": "1.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/rect": {
+ "version": "1.1.1",
+ "license": "MIT"
+ },
+ "node_modules/@rtsao/scc": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.15",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.1.17",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "enhanced-resolve": "^5.18.3",
+ "jiti": "^2.6.1",
+ "lightningcss": "1.30.2",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.1.17"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.1.17",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.1.17",
+ "@tailwindcss/oxide-darwin-arm64": "4.1.17",
+ "@tailwindcss/oxide-darwin-x64": "4.1.17",
+ "@tailwindcss/oxide-freebsd-x64": "4.1.17",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.1.17",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.1.17",
+ "@tailwindcss/oxide-linux-x64-musl": "4.1.17",
+ "@tailwindcss/oxide-wasm32-wasi": "4.1.17",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.1.17"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz",
+ "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz",
+ "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz",
+ "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz",
+ "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz",
+ "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz",
+ "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz",
+ "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz",
+ "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz",
+ "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz",
+ "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.6.0",
+ "@emnapi/runtime": "^1.6.0",
+ "@emnapi/wasi-threads": "^1.1.0",
+ "@napi-rs/wasm-runtime": "^1.0.7",
+ "@tybys/wasm-util": "^0.10.1",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.1.17",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz",
+ "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.1.17",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/postcss": {
+ "version": "4.1.17",
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "@tailwindcss/node": "4.1.17",
+ "@tailwindcss/oxide": "4.1.17",
+ "postcss": "^8.4.41",
+ "tailwindcss": "4.1.17"
+ }
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.1",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/debug": {
+ "version": "4.1.12",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree-jsx": {
+ "version": "1.0.5",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/@types/hast": {
+ "version": "3.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json5": {
+ "version": "0.0.29",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/mdast": {
+ "version": "4.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/parse-json": {
+ "version": "4.0.2",
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "license": "MIT"
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.46.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.10.0",
+ "@typescript-eslint/scope-manager": "8.46.4",
+ "@typescript-eslint/type-utils": "8.46.4",
+ "@typescript-eslint/utils": "8.46.4",
+ "@typescript-eslint/visitor-keys": "8.46.4",
+ "graphemer": "^1.4.0",
+ "ignore": "^7.0.0",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.1.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.46.4",
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.5",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.46.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.46.4",
+ "@typescript-eslint/types": "8.46.4",
+ "@typescript-eslint/typescript-estree": "8.46.4",
+ "@typescript-eslint/visitor-keys": "8.46.4",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.46.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.46.4",
+ "@typescript-eslint/types": "^8.46.4",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.46.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.46.4",
+ "@typescript-eslint/visitor-keys": "8.46.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.46.4",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.46.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.46.4",
+ "@typescript-eslint/typescript-estree": "8.46.4",
+ "@typescript-eslint/utils": "8.46.4",
+ "debug": "^4.3.4",
+ "ts-api-utils": "^2.1.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.46.4",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.46.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.46.4",
+ "@typescript-eslint/tsconfig-utils": "8.46.4",
+ "@typescript-eslint/types": "8.46.4",
+ "@typescript-eslint/visitor-keys": "8.46.4",
+ "debug": "^4.3.4",
+ "fast-glob": "^3.3.2",
+ "is-glob": "^4.0.3",
+ "minimatch": "^9.0.4",
+ "semver": "^7.6.0",
+ "ts-api-utils": "^2.1.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/fast-glob": {
+ "version": "3.3.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "9.0.5",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+ "version": "7.7.3",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.46.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.7.0",
+ "@typescript-eslint/scope-manager": "8.46.4",
+ "@typescript-eslint/types": "8.46.4",
+ "@typescript-eslint/typescript-estree": "8.46.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.46.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.46.4",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.3.0",
+ "license": "ISC"
+ },
+ "node_modules/@unrs/resolver-binding-android-arm-eabi": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
+ "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-android-arm64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz",
+ "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-darwin-arm64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz",
+ "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-darwin-x64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz",
+ "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-freebsd-x64": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz",
+ "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz",
+ "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz",
+ "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz",
+ "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-arm64-musl": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz",
+ "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz",
+ "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz",
+ "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz",
+ "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz",
+ "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-x64-gnu": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz",
+ "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-linux-x64-musl": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz",
+ "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-wasm32-wasi": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz",
+ "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@napi-rs/wasm-runtime": "^0.2.11"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz",
+ "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz",
+ "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@unrs/resolver-binding-win32-x64-msvc": {
+ "version": "1.11.1",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@upstash/redis": {
+ "version": "v1.35.6",
+ "license": "MIT",
+ "dependencies": {
+ "uncrypto": "^0.1.3"
+ }
+ },
+ "node_modules/@vercel/analytics": {
+ "version": "1.5.0",
+ "license": "MPL-2.0",
+ "peerDependencies": {
+ "@remix-run/react": "^2",
+ "@sveltejs/kit": "^1 || ^2",
+ "next": ">= 13",
+ "react": "^18 || ^19 || ^19.0.0-rc",
+ "svelte": ">= 4",
+ "vue": "^3",
+ "vue-router": "^4"
+ },
+ "peerDependenciesMeta": {
+ "@remix-run/react": {
+ "optional": true
+ },
+ "@sveltejs/kit": {
+ "optional": true
+ },
+ "next": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "svelte": {
+ "optional": true
+ },
+ "vue": {
+ "optional": true
+ },
+ "vue-router": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vercel/speed-insights": {
+ "version": "1.2.0",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "peerDependencies": {
+ "@sveltejs/kit": "^1 || ^2",
+ "next": ">= 13",
+ "react": "^18 || ^19 || ^19.0.0-rc",
+ "svelte": ">= 4",
+ "vue": "^3",
+ "vue-router": "^4"
+ },
+ "peerDependenciesMeta": {
+ "@sveltejs/kit": {
+ "optional": true
+ },
+ "next": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "svelte": {
+ "optional": true
+ },
+ "vue": {
+ "optional": true
+ },
+ "vue-router": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.12.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/aria-hidden": {
+ "version": "1.2.6",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.2",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/array-buffer-byte-length": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "is-array-buffer": "^3.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array-includes": {
+ "version": "3.1.9",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.24.0",
+ "es-object-atoms": "^1.1.1",
+ "get-intrinsic": "^1.3.0",
+ "is-string": "^1.1.1",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.findlast": {
+ "version": "1.2.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.findlastindex": {
+ "version": "1.2.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.9",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "es-shim-unscopables": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.flat": {
+ "version": "1.3.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.flatmap": {
+ "version": "1.3.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.tosorted": {
+ "version": "1.1.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.3",
+ "es-errors": "^1.3.0",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/arraybuffer.prototype.slice": {
+ "version": "1.0.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.1",
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "is-array-buffer": "^3.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/ast-types-flow": {
+ "version": "0.0.8",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/async-function": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.4.22",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.27.0",
+ "caniuse-lite": "^1.0.30001754",
+ "fraction.js": "^5.3.4",
+ "normalize-range": "^0.1.2",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/available-typed-arrays": {
+ "version": "1.0.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "possible-typed-array-names": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/axe-core": {
+ "version": "4.11.0",
+ "dev": true,
+ "license": "MPL-2.0",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/babel-plugin-macros": {
+ "version": "3.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "cosmiconfig": "^7.0.0",
+ "resolve": "^1.19.0"
+ },
+ "engines": {
+ "node": ">=10",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/bail": {
+ "version": "2.0.2",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.8.32",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.js"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.0",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.8.25",
+ "caniuse-lite": "^1.0.30001754",
+ "electron-to-chromium": "^1.5.249",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.1.4"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/call-bind": {
+ "version": "1.0.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.0",
+ "es-define-property": "^1.0.0",
+ "get-intrinsic": "^1.2.4",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/camelcase": {
+ "version": "8.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/camelcase-keys": {
+ "version": "9.1.3",
+ "license": "MIT",
+ "dependencies": {
+ "camelcase": "^8.0.0",
+ "map-obj": "5.0.0",
+ "quick-lru": "^6.1.1",
+ "type-fest": "^4.3.2"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001754",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/ccount": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/character-entities": {
+ "version": "2.0.2",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-html4": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-reference-invalid": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/class-variance-authority": {
+ "version": "0.7.1",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "clsx": "^2.1.1"
+ },
+ "funding": {
+ "url": "https://polar.sh/cva"
+ }
+ },
+ "node_modules/classnames": {
+ "version": "2.5.1",
+ "license": "MIT"
+ },
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "license": "MIT"
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/comma-separated-tokens": {
+ "version": "2.0.3",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cosmiconfig": {
+ "version": "7.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/parse-json": "^4.0.0",
+ "import-fresh": "^3.2.1",
+ "parse-json": "^5.0.0",
+ "path-type": "^4.0.0",
+ "yaml": "^1.10.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "license": "MIT"
+ },
+ "node_modules/damerau-levenshtein": {
+ "version": "1.0.8",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/data-view-buffer": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/data-view-byte-length": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/inspect-js"
+ }
+ },
+ "node_modules/data-view-byte-offset": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decode-named-character-reference": {
+ "version": "1.2.0",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/detect-node-es": {
+ "version": "1.1.0",
+ "license": "MIT"
+ },
+ "node_modules/devlop": {
+ "version": "1.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/diff": {
+ "version": "5.2.0",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.3.1"
+ }
+ },
+ "node_modules/doctrine": {
+ "version": "2.1.0",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.250",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.18.3",
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/error-ex": {
+ "version": "1.3.4",
+ "license": "MIT",
+ "dependencies": {
+ "is-arrayish": "^0.2.1"
+ }
+ },
+ "node_modules/es-abstract": {
+ "version": "1.24.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.2",
+ "arraybuffer.prototype.slice": "^1.0.4",
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "data-view-buffer": "^1.0.2",
+ "data-view-byte-length": "^1.0.2",
+ "data-view-byte-offset": "^1.0.1",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "es-set-tostringtag": "^2.1.0",
+ "es-to-primitive": "^1.3.0",
+ "function.prototype.name": "^1.1.8",
+ "get-intrinsic": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "get-symbol-description": "^1.1.0",
+ "globalthis": "^1.0.4",
+ "gopd": "^1.2.0",
+ "has-property-descriptors": "^1.0.2",
+ "has-proto": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "internal-slot": "^1.1.0",
+ "is-array-buffer": "^3.0.5",
+ "is-callable": "^1.2.7",
+ "is-data-view": "^1.0.2",
+ "is-negative-zero": "^2.0.3",
+ "is-regex": "^1.2.1",
+ "is-set": "^2.0.3",
+ "is-shared-array-buffer": "^1.0.4",
+ "is-string": "^1.1.1",
+ "is-typed-array": "^1.1.15",
+ "is-weakref": "^1.1.1",
+ "math-intrinsics": "^1.1.0",
+ "object-inspect": "^1.13.4",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.7",
+ "own-keys": "^1.0.1",
+ "regexp.prototype.flags": "^1.5.4",
+ "safe-array-concat": "^1.1.3",
+ "safe-push-apply": "^1.0.0",
+ "safe-regex-test": "^1.1.0",
+ "set-proto": "^1.0.0",
+ "stop-iteration-iterator": "^1.1.0",
+ "string.prototype.trim": "^1.2.10",
+ "string.prototype.trimend": "^1.0.9",
+ "string.prototype.trimstart": "^1.0.8",
+ "typed-array-buffer": "^1.0.3",
+ "typed-array-byte-length": "^1.0.3",
+ "typed-array-byte-offset": "^1.0.4",
+ "typed-array-length": "^1.0.7",
+ "unbox-primitive": "^1.1.0",
+ "which-typed-array": "^1.1.19"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-iterator-helpers": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.6",
+ "es-errors": "^1.3.0",
+ "es-set-tostringtag": "^2.0.3",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.6",
+ "globalthis": "^1.0.4",
+ "gopd": "^1.2.0",
+ "has-property-descriptors": "^1.0.2",
+ "has-proto": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "internal-slot": "^1.1.0",
+ "iterator.prototype": "^1.1.4",
+ "safe-array-concat": "^1.1.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-shim-unscopables": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-to-primitive": {
+ "version": "1.3.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7",
+ "is-date-object": "^1.0.5",
+ "is-symbol": "^1.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.39.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.1",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
+ "@eslint/eslintrc": "^3.3.1",
+ "@eslint/js": "9.39.1",
+ "@eslint/plugin-kit": "^0.4.1",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.12.4",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-config-next": {
+ "version": "16.0.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@next/eslint-plugin-next": "16.0.10",
+ "eslint-import-resolver-node": "^0.3.6",
+ "eslint-import-resolver-typescript": "^3.5.2",
+ "eslint-plugin-import": "^2.32.0",
+ "eslint-plugin-jsx-a11y": "^6.10.0",
+ "eslint-plugin-react": "^7.37.0",
+ "eslint-plugin-react-hooks": "^7.0.0",
+ "globals": "16.4.0",
+ "typescript-eslint": "^8.46.0"
+ },
+ "peerDependencies": {
+ "eslint": ">=9.0.0",
+ "typescript": ">=3.3.1"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-config-next/node_modules/globals": {
+ "version": "16.4.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
+ "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint-import-resolver-node": {
+ "version": "0.3.9",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^3.2.7",
+ "is-core-module": "^2.13.0",
+ "resolve": "^1.22.4"
+ }
+ },
+ "node_modules/eslint-import-resolver-node/node_modules/debug": {
+ "version": "3.2.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/eslint-import-resolver-typescript": {
+ "version": "3.10.1",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@nolyfill/is-core-module": "1.0.39",
+ "debug": "^4.4.0",
+ "get-tsconfig": "^4.10.0",
+ "is-bun-module": "^2.0.0",
+ "stable-hash": "^0.0.5",
+ "tinyglobby": "^0.2.13",
+ "unrs-resolver": "^1.6.2"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint-import-resolver-typescript"
+ },
+ "peerDependencies": {
+ "eslint": "*",
+ "eslint-plugin-import": "*",
+ "eslint-plugin-import-x": "*"
+ },
+ "peerDependenciesMeta": {
+ "eslint-plugin-import": {
+ "optional": true
+ },
+ "eslint-plugin-import-x": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-module-utils": {
+ "version": "2.12.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^3.2.7"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependenciesMeta": {
+ "eslint": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-module-utils/node_modules/debug": {
+ "version": "3.2.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/eslint-plugin-import": {
+ "version": "2.32.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rtsao/scc": "^1.1.0",
+ "array-includes": "^3.1.9",
+ "array.prototype.findlastindex": "^1.2.6",
+ "array.prototype.flat": "^1.3.3",
+ "array.prototype.flatmap": "^1.3.3",
+ "debug": "^3.2.7",
+ "doctrine": "^2.1.0",
+ "eslint-import-resolver-node": "^0.3.9",
+ "eslint-module-utils": "^2.12.1",
+ "hasown": "^2.0.2",
+ "is-core-module": "^2.16.1",
+ "is-glob": "^4.0.3",
+ "minimatch": "^3.1.2",
+ "object.fromentries": "^2.0.8",
+ "object.groupby": "^1.0.3",
+ "object.values": "^1.2.1",
+ "semver": "^6.3.1",
+ "string.prototype.trimend": "^1.0.9",
+ "tsconfig-paths": "^3.15.0"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependencies": {
+ "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9"
+ }
+ },
+ "node_modules/eslint-plugin-import/node_modules/debug": {
+ "version": "3.2.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/eslint-plugin-jsx-a11y": {
+ "version": "6.10.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "aria-query": "^5.3.2",
+ "array-includes": "^3.1.8",
+ "array.prototype.flatmap": "^1.3.2",
+ "ast-types-flow": "^0.0.8",
+ "axe-core": "^4.10.0",
+ "axobject-query": "^4.1.0",
+ "damerau-levenshtein": "^1.0.8",
+ "emoji-regex": "^9.2.2",
+ "hasown": "^2.0.2",
+ "jsx-ast-utils": "^3.3.5",
+ "language-tags": "^1.0.9",
+ "minimatch": "^3.1.2",
+ "object.fromentries": "^2.0.8",
+ "safe-regex-test": "^1.0.3",
+ "string.prototype.includes": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependencies": {
+ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9"
+ }
+ },
+ "node_modules/eslint-plugin-react": {
+ "version": "7.37.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-includes": "^3.1.8",
+ "array.prototype.findlast": "^1.2.5",
+ "array.prototype.flatmap": "^1.3.3",
+ "array.prototype.tosorted": "^1.1.4",
+ "doctrine": "^2.1.0",
+ "es-iterator-helpers": "^1.2.1",
+ "estraverse": "^5.3.0",
+ "hasown": "^2.0.2",
+ "jsx-ast-utils": "^2.4.1 || ^3.0.0",
+ "minimatch": "^3.1.2",
+ "object.entries": "^1.1.9",
+ "object.fromentries": "^2.0.8",
+ "object.values": "^1.2.1",
+ "prop-types": "^15.8.1",
+ "resolve": "^2.0.0-next.5",
+ "semver": "^6.3.1",
+ "string.prototype.matchall": "^4.0.12",
+ "string.prototype.repeat": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ },
+ "peerDependencies": {
+ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7"
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "7.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.24.4",
+ "@babel/parser": "^7.24.4",
+ "hermes-parser": "^0.25.1",
+ "zod": "^3.25.0 || ^4.0.0",
+ "zod-validation-error": "^3.5.0 || ^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-react/node_modules/resolve": {
+ "version": "2.0.0-next.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.13.0",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.4.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "10.4.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.6.0",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estree-util-is-identifier-name": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/events": {
+ "version": "3.3.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.x"
+ }
+ },
+ "node_modules/extend": {
+ "version": "3.0.2",
+ "license": "MIT"
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fastq": {
+ "version": "1.19.1",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-root": {
+ "version": "1.1.0",
+ "license": "MIT"
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.3.3",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/for-each": {
+ "version": "0.3.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/framer-motion": {
+ "version": "12.23.26",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz",
+ "integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^12.23.23",
+ "motion-utils": "^12.23.6",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/function.prototype.name": {
+ "version": "1.1.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "functions-have-names": "^1.2.3",
+ "hasown": "^2.0.2",
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/functions-have-names": {
+ "version": "1.2.3",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/generator-function": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-nonce": {
+ "version": "1.0.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-symbol-description": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-tsconfig": {
+ "version": "4.13.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "14.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/globalthis": {
+ "version": "1.0.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-properties": "^1.2.1",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "license": "ISC"
+ },
+ "node_modules/graphemer": {
+ "version": "1.4.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/has-bigints": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-proto": {
+ "version": "1.2.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hast-util-from-parse5": {
+ "version": "8.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "devlop": "^1.0.0",
+ "hastscript": "^9.0.0",
+ "property-information": "^7.0.0",
+ "vfile": "^6.0.0",
+ "vfile-location": "^5.0.0",
+ "web-namespaces": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-is-element": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-parse-selector": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-raw": {
+ "version": "9.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "hast-util-from-parse5": "^8.0.0",
+ "hast-util-to-parse5": "^8.0.0",
+ "html-void-elements": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "parse5": "^7.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0",
+ "web-namespaces": "^2.0.0",
+ "zwitch": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-to-jsx-runtime": {
+ "version": "2.3.6",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "devlop": "^1.0.0",
+ "estree-util-is-identifier-name": "^3.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "mdast-util-mdx-expression": "^2.0.0",
+ "mdast-util-mdx-jsx": "^3.0.0",
+ "mdast-util-mdxjs-esm": "^2.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "style-to-js": "^1.0.0",
+ "unist-util-position": "^5.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-to-parse5": {
+ "version": "8.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "devlop": "^1.0.0",
+ "property-information": "^6.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "web-namespaces": "^2.0.0",
+ "zwitch": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-to-parse5/node_modules/property-information": {
+ "version": "6.5.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/hast-util-to-text": {
+ "version": "4.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "hast-util-is-element": "^3.0.0",
+ "unist-util-find-after": "^5.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-whitespace": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hastscript": {
+ "version": "9.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "hast-util-parse-selector": "^4.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hermes-estree": {
+ "version": "0.25.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/hermes-parser": {
+ "version": "0.25.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hermes-estree": "0.25.1"
+ }
+ },
+ "node_modules/highlight.js": {
+ "version": "11.11.1",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/html-url-attributes": {
+ "version": "3.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/html-void-elements": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/inline-style-parser": {
+ "version": "0.2.6",
+ "license": "MIT"
+ },
+ "node_modules/internal-slot": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "hasown": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/is-alphabetical": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-alphanumerical": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "is-alphabetical": "^2.0.0",
+ "is-decimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-array-buffer": {
+ "version": "3.0.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-arrayish": {
+ "version": "0.2.1",
+ "license": "MIT"
+ },
+ "node_modules/is-async-function": {
+ "version": "2.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "async-function": "^1.0.0",
+ "call-bound": "^1.0.3",
+ "get-proto": "^1.0.1",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-bigint": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-bigints": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-boolean-object": {
+ "version": "1.2.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-bun-module": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.7.1"
+ }
+ },
+ "node_modules/is-bun-module/node_modules/semver": {
+ "version": "7.7.3",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-data-view": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "get-intrinsic": "^1.2.6",
+ "is-typed-array": "^1.1.13"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-date-object": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-decimal": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-finalizationregistry": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-generator-function": {
+ "version": "1.1.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.4",
+ "generator-function": "^2.0.0",
+ "get-proto": "^1.0.1",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-hexadecimal": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/is-map": {
+ "version": "2.0.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-negative-zero": {
+ "version": "2.0.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-number-object": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-plain-obj": {
+ "version": "4.1.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-regex": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-set": {
+ "version": "2.0.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-shared-array-buffer": {
+ "version": "1.0.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-string": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-symbol": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-typed-array": {
+ "version": "1.1.15",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakmap": {
+ "version": "2.0.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakref": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakset": {
+ "version": "2.0.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "2.0.5",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/iterator.prototype": {
+ "version": "1.1.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.6",
+ "get-proto": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.6.1",
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/jose": {
+ "version": "6.1.1",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-parse-even-better-errors": {
+ "version": "2.3.1",
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/jsx-ast-utils": {
+ "version": "3.3.5",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-includes": "^3.1.6",
+ "array.prototype.flat": "^1.3.1",
+ "object.assign": "^4.1.4",
+ "object.values": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/language-subtag-registry": {
+ "version": "0.3.23",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/language-tags": {
+ "version": "1.0.9",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "language-subtag-registry": "^0.3.20"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.30.2",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.30.2",
+ "lightningcss-darwin-arm64": "1.30.2",
+ "lightningcss-darwin-x64": "1.30.2",
+ "lightningcss-freebsd-x64": "1.30.2",
+ "lightningcss-linux-arm-gnueabihf": "1.30.2",
+ "lightningcss-linux-arm64-gnu": "1.30.2",
+ "lightningcss-linux-arm64-musl": "1.30.2",
+ "lightningcss-linux-x64-gnu": "1.30.2",
+ "lightningcss-linux-x64-musl": "1.30.2",
+ "lightningcss-win32-arm64-msvc": "1.30.2",
+ "lightningcss-win32-x64-msvc": "1.30.2"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
+ "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
+ "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
+ "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
+ "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
+ "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
+ "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
+ "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
+ "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
+ "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
+ "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.30.2",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "license": "MIT"
+ },
+ "node_modules/livekit-client": {
+ "version": "2.16.1",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@livekit/mutex": "1.1.1",
+ "@livekit/protocol": "1.42.2",
+ "events": "^3.3.0",
+ "jose": "^6.1.0",
+ "loglevel": "^1.9.2",
+ "sdp-transform": "^2.15.0",
+ "ts-debounce": "^4.0.0",
+ "tslib": "2.8.1",
+ "typed-emitter": "^2.1.0",
+ "webrtc-adapter": "^9.0.1"
+ },
+ "peerDependencies": {
+ "@types/dom-mediacapture-record": "^1"
+ }
+ },
+ "node_modules/livekit-server-sdk": {
+ "version": "2.15.0",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@bufbuild/protobuf": "^1.10.1",
+ "@livekit/protocol": "^1.43.1",
+ "camelcase-keys": "^9.0.0",
+ "jose": "^5.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/livekit-server-sdk/node_modules/@livekit/protocol": {
+ "version": "1.43.4",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@bufbuild/protobuf": "^1.10.0"
+ }
+ },
+ "node_modules/livekit-server-sdk/node_modules/jose": {
+ "version": "5.10.0",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
+ "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loglevel": {
+ "version": "1.9.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ },
+ "funding": {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/loglevel"
+ }
+ },
+ "node_modules/longest-streak": {
+ "version": "3.1.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lowlight": {
+ "version": "3.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "devlop": "^1.0.0",
+ "highlight.js": "~11.11.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "0.554.0",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/map-obj": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/markdown-table": {
+ "version": "3.0.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/mdast-util-find-and-replace": {
+ "version": "3.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "escape-string-regexp": "^5.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mdast-util-from-markdown": {
+ "version": "2.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark": "^4.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm": {
+ "version": "3.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-gfm-autolink-literal": "^2.0.0",
+ "mdast-util-gfm-footnote": "^2.0.0",
+ "mdast-util-gfm-strikethrough": "^2.0.0",
+ "mdast-util-gfm-table": "^2.0.0",
+ "mdast-util-gfm-task-list-item": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-autolink-literal": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-find-and-replace": "^3.0.0",
+ "micromark-util-character": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-footnote": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-strikethrough": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-table": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "markdown-table": "^3.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-gfm-task-list-item": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-expression": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdx-jsx": {
+ "version": "3.2.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "devlop": "^1.1.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "parse-entities": "^4.0.0",
+ "stringify-entities": "^4.0.0",
+ "unist-util-stringify-position": "^4.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-mdxjs-esm": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree-jsx": "^1.0.0",
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "mdast-util-to-markdown": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-phrasing": {
+ "version": "4.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-hast": {
+ "version": "13.2.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "trim-lines": "^3.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-markdown": {
+ "version": "2.1.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "@types/unist": "^3.0.0",
+ "longest-streak": "^3.0.0",
+ "mdast-util-phrasing": "^4.0.0",
+ "mdast-util-to-string": "^4.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-decode-string": "^2.0.0",
+ "unist-util-visit": "^5.0.0",
+ "zwitch": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/mdast-util-to-string": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/memoize-one": {
+ "version": "6.0.0",
+ "license": "MIT"
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromark": {
+ "version": "4.0.2",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@types/debug": "^4.0.0",
+ "debug": "^4.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-core-commonmark": {
+ "version": "2.0.3",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-factory-destination": "^2.0.0",
+ "micromark-factory-label": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-factory-title": "^2.0.0",
+ "micromark-factory-whitespace": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-html-tag-name": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-subtokenize": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-extension-gfm": {
+ "version": "3.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-extension-gfm-autolink-literal": "^2.0.0",
+ "micromark-extension-gfm-footnote": "^2.0.0",
+ "micromark-extension-gfm-strikethrough": "^2.0.0",
+ "micromark-extension-gfm-table": "^2.0.0",
+ "micromark-extension-gfm-tagfilter": "^2.0.0",
+ "micromark-extension-gfm-task-list-item": "^2.0.0",
+ "micromark-util-combine-extensions": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-autolink-literal": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-footnote": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-core-commonmark": "^2.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-normalize-identifier": "^2.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-strikethrough": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-classify-character": "^2.0.0",
+ "micromark-util-resolve-all": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-table": {
+ "version": "2.1.1",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-tagfilter": {
+ "version": "2.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-extension-gfm-task-list-item": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-factory-destination": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-label": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-space": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-title": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-factory-whitespace": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-factory-space": "^2.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-character": {
+ "version": "2.1.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-chunked": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-classify-character": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-combine-extensions": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-numeric-character-reference": {
+ "version": "2.0.2",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-decode-string": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decode-named-character-reference": "^1.0.0",
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-encode": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-html-tag-name": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-normalize-identifier": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-resolve-all": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-sanitize-uri": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-subtokenize": {
+ "version": "2.1.0",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "devlop": "^1.0.0",
+ "micromark-util-chunked": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-symbol": {
+ "version": "2.0.1",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-types": {
+ "version": "2.0.2",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/motion-dom": {
+ "version": "12.23.23",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
+ "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.23.6"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.23.6",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
+ "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/napi-postinstall": {
+ "version": "0.3.4",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "napi-postinstall": "lib/cli.js"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/napi-postinstall"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/next": {
+ "version": "16.0.10",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "16.0.10",
+ "@swc/helpers": "0.5.15",
+ "caniuse-lite": "^1.0.30001579",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.6"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "16.0.10",
+ "@next/swc-darwin-x64": "16.0.10",
+ "@next/swc-linux-arm64-gnu": "16.0.10",
+ "@next/swc-linux-arm64-musl": "16.0.10",
+ "@next/swc-linux-x64-gnu": "16.0.10",
+ "@next/swc-linux-x64-musl": "16.0.10",
+ "@next/swc-win32-arm64-msvc": "16.0.10",
+ "@next/swc-win32-x64-msvc": "16.0.10",
+ "sharp": "^0.34.4"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.51.1",
+ "babel-plugin-react-compiler": "*",
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next-themes": {
+ "version": "0.4.6",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/next/node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.27",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/normalize-range": {
+ "version": "0.1.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.assign": {
+ "version": "4.1.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.entries": {
+ "version": "1.1.9",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.fromentries": {
+ "version": "2.0.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.groupby": {
+ "version": "1.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.values": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/own-keys": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-intrinsic": "^1.2.6",
+ "object-keys": "^1.1.1",
+ "safe-push-apply": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse-entities": {
+ "version": "4.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^2.0.0",
+ "character-entities-legacy": "^3.0.0",
+ "character-reference-invalid": "^2.0.0",
+ "decode-named-character-reference": "^1.0.0",
+ "is-alphanumerical": "^2.0.0",
+ "is-decimal": "^2.0.0",
+ "is-hexadecimal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/parse-entities/node_modules/@types/unist": {
+ "version": "2.0.11",
+ "license": "MIT"
+ },
+ "node_modules/parse-json": {
+ "version": "5.2.0",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.0.0",
+ "error-ex": "^1.3.1",
+ "json-parse-even-better-errors": "^2.3.0",
+ "lines-and-columns": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "license": "MIT"
+ },
+ "node_modules/path-type": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/possible-typed-array-names": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/prop-types": {
+ "version": "15.8.1",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "node_modules/property-information": {
+ "version": "7.1.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/quick-lru": {
+ "version": "6.1.2",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.3",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-diff-viewer-continued": {
+ "version": "3.4.0",
+ "license": "MIT",
+ "dependencies": {
+ "@emotion/css": "^11.11.2",
+ "classnames": "^2.3.2",
+ "diff": "^5.1.0",
+ "memoize-one": "^6.0.0",
+ "prop-types": "^15.8.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ },
+ "peerDependencies": {
+ "react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0",
+ "react-dom": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.3"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "16.13.1",
+ "license": "MIT"
+ },
+ "node_modules/react-markdown": {
+ "version": "10.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "devlop": "^1.0.0",
+ "hast-util-to-jsx-runtime": "^2.0.0",
+ "html-url-attributes": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-rehype": "^11.0.0",
+ "unified": "^11.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18",
+ "react": ">=18"
+ }
+ },
+ "node_modules/react-remove-scroll": {
+ "version": "2.7.1",
+ "license": "MIT",
+ "dependencies": {
+ "react-remove-scroll-bar": "^2.3.7",
+ "react-style-singleton": "^2.2.3",
+ "tslib": "^2.1.0",
+ "use-callback-ref": "^1.3.3",
+ "use-sidecar": "^1.1.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-remove-scroll-bar": {
+ "version": "2.3.8",
+ "license": "MIT",
+ "dependencies": {
+ "react-style-singleton": "^2.2.2",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-style-singleton": {
+ "version": "2.2.3",
+ "license": "MIT",
+ "dependencies": {
+ "get-nonce": "^1.0.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/reflect.getprototypeof": {
+ "version": "1.0.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.9",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.7",
+ "get-proto": "^1.0.1",
+ "which-builtin-type": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/regexp.prototype.flags": {
+ "version": "1.5.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-errors": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/rehype-highlight": {
+ "version": "7.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "hast-util-to-text": "^4.0.0",
+ "lowlight": "^3.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/rehype-raw": {
+ "version": "7.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "hast-util-raw": "^9.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-gfm": {
+ "version": "4.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-gfm": "^3.0.0",
+ "micromark-extension-gfm": "^3.0.0",
+ "remark-parse": "^11.0.0",
+ "remark-stringify": "^11.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-parse": {
+ "version": "11.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-from-markdown": "^2.0.0",
+ "micromark-util-types": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-rehype": {
+ "version": "11.1.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "unified": "^11.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/remark-stringify": {
+ "version": "11.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mdast": "^4.0.0",
+ "mdast-util-to-markdown": "^2.0.0",
+ "unified": "^11.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.11",
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/rxjs": {
+ "version": "7.8.2",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
+ "node_modules/safe-array-concat": {
+ "version": "1.1.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "get-intrinsic": "^1.2.6",
+ "has-symbols": "^1.1.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">=0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-push-apply": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-regex-test": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-regex": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "license": "MIT"
+ },
+ "node_modules/sdp": {
+ "version": "3.2.1",
+ "license": "MIT"
+ },
+ "node_modules/sdp-transform": {
+ "version": "2.15.0",
+ "license": "MIT",
+ "bin": {
+ "sdp-verify": "checker.js"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-function-name": {
+ "version": "2.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "functions-have-names": "^1.2.3",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-proto": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/sharp/node_modules/semver": {
+ "version": "7.7.3",
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/sonner": {
+ "version": "2.0.7",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
+ "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.5.7",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/space-separated-tokens": {
+ "version": "2.0.2",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/stable-hash": {
+ "version": "0.0.5",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/stop-iteration-iterator": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "internal-slot": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/string.prototype.includes": {
+ "version": "2.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/string.prototype.matchall": {
+ "version": "4.0.12",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.6",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.6",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "internal-slot": "^1.1.0",
+ "regexp.prototype.flags": "^1.5.3",
+ "set-function-name": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.repeat": {
+ "version": "1.0.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.17.5"
+ }
+ },
+ "node_modules/string.prototype.trim": {
+ "version": "1.2.10",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "define-data-property": "^1.1.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-object-atoms": "^1.0.0",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimend": {
+ "version": "1.0.9",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.2",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimstart": {
+ "version": "1.0.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/stringify-entities": {
+ "version": "4.0.4",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities-html4": "^2.0.0",
+ "character-entities-legacy": "^3.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/strip-bom": {
+ "version": "3.0.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/style-to-js": {
+ "version": "1.1.19",
+ "license": "MIT",
+ "dependencies": {
+ "style-to-object": "1.0.12"
+ }
+ },
+ "node_modules/style-to-object": {
+ "version": "1.0.12",
+ "license": "MIT",
+ "dependencies": {
+ "inline-style-parser": "0.2.6"
+ }
+ },
+ "node_modules/styled-jsx": {
+ "version": "5.1.6",
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/stylis": {
+ "version": "4.2.0",
+ "license": "MIT"
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tailwind-merge": {
+ "version": "3.4.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.1.17",
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.0",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.3",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/trim-lines": {
+ "version": "3.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/trough": {
+ "version": "2.2.0",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/ts-debounce": {
+ "version": "4.0.0",
+ "license": "MIT"
+ },
+ "node_modules/tsconfig-paths": {
+ "version": "3.15.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/json5": "^0.0.29",
+ "json5": "^1.0.2",
+ "minimist": "^1.2.6",
+ "strip-bom": "^3.0.0"
+ }
+ },
+ "node_modules/tsconfig-paths/node_modules/json5": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "minimist": "^1.2.0"
+ },
+ "bin": {
+ "json5": "lib/cli.js"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "license": "0BSD"
+ },
+ "node_modules/tw-animate-css": {
+ "version": "1.4.0",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Wombosvideo"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "4.41.0",
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/typed-array-buffer": {
+ "version": "1.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/typed-array-byte-length": {
+ "version": "1.0.3",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "for-each": "^0.3.3",
+ "gopd": "^1.2.0",
+ "has-proto": "^1.2.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-byte-offset": {
+ "version": "1.0.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "for-each": "^0.3.3",
+ "gopd": "^1.2.0",
+ "has-proto": "^1.2.0",
+ "is-typed-array": "^1.1.15",
+ "reflect.getprototypeof": "^1.0.9"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-length": {
+ "version": "1.0.7",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "for-each": "^0.3.3",
+ "gopd": "^1.0.1",
+ "is-typed-array": "^1.1.13",
+ "possible-typed-array-names": "^1.0.0",
+ "reflect.getprototypeof": "^1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-emitter": {
+ "version": "2.1.0",
+ "license": "MIT",
+ "optionalDependencies": {
+ "rxjs": "*"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.46.4",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.46.4",
+ "@typescript-eslint/parser": "8.46.4",
+ "@typescript-eslint/typescript-estree": "8.46.4",
+ "@typescript-eslint/utils": "8.46.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0",
+ "typescript": ">=4.8.4 <6.0.0"
+ }
+ },
+ "node_modules/unbox-primitive": {
+ "version": "1.1.0",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-bigints": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "which-boxed-primitive": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/uncrypto": {
+ "version": "0.1.3",
+ "license": "MIT"
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/unified": {
+ "version": "11.0.5",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "bail": "^2.0.0",
+ "devlop": "^1.0.0",
+ "extend": "^3.0.0",
+ "is-plain-obj": "^4.0.0",
+ "trough": "^2.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-find-after": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-is": {
+ "version": "6.0.1",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-stringify-position": {
+ "version": "4.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "5.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-parents": {
+ "version": "6.0.2",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unrs-resolver": {
+ "version": "1.11.1",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "napi-postinstall": "^0.3.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/unrs-resolver"
+ },
+ "optionalDependencies": {
+ "@unrs/resolver-binding-android-arm-eabi": "1.11.1",
+ "@unrs/resolver-binding-android-arm64": "1.11.1",
+ "@unrs/resolver-binding-darwin-arm64": "1.11.1",
+ "@unrs/resolver-binding-darwin-x64": "1.11.1",
+ "@unrs/resolver-binding-freebsd-x64": "1.11.1",
+ "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1",
+ "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1",
+ "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-arm64-musl": "1.11.1",
+ "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1",
+ "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-x64-gnu": "1.11.1",
+ "@unrs/resolver-binding-linux-x64-musl": "1.11.1",
+ "@unrs/resolver-binding-wasm32-wasi": "1.11.1",
+ "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1",
+ "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1",
+ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.1.4",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/use-callback-ref": {
+ "version": "1.3.3",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-sidecar": {
+ "version": "1.1.3",
+ "license": "MIT",
+ "dependencies": {
+ "detect-node-es": "^1.1.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vfile": {
+ "version": "6.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-location": {
+ "version": "5.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-message": {
+ "version": "4.0.3",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/web-namespaces": {
+ "version": "2.0.1",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/webrtc-adapter": {
+ "version": "9.0.3",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "sdp": "^3.2.0"
+ },
+ "engines": {
+ "node": ">=6.0.0",
+ "npm": ">=3.10.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/which-boxed-primitive": {
+ "version": "1.1.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-bigint": "^1.1.0",
+ "is-boolean-object": "^1.2.1",
+ "is-number-object": "^1.1.1",
+ "is-string": "^1.1.1",
+ "is-symbol": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-builtin-type": {
+ "version": "1.2.1",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "function.prototype.name": "^1.1.6",
+ "has-tostringtag": "^1.0.2",
+ "is-async-function": "^2.0.0",
+ "is-date-object": "^1.1.0",
+ "is-finalizationregistry": "^1.1.0",
+ "is-generator-function": "^1.0.10",
+ "is-regex": "^1.2.1",
+ "is-weakref": "^1.0.2",
+ "isarray": "^2.0.5",
+ "which-boxed-primitive": "^1.1.0",
+ "which-collection": "^1.0.2",
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-collection": {
+ "version": "1.0.2",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-map": "^2.0.3",
+ "is-set": "^2.0.3",
+ "is-weakmap": "^2.0.2",
+ "is-weakset": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-typed-array": {
+ "version": "1.1.19",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "for-each": "^0.3.5",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yaml": {
+ "version": "1.10.2",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.1.12",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-validation-error": {
+ "version": "4.0.2",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ }
+ },
+ "node_modules/zwitch": {
+ "version": "2.0.4",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..586d2787de876391b8e5af8969d2565b6ae90c1f
--- /dev/null
+++ b/package.json
@@ -0,0 +1,60 @@
+{
+ "name": "frontend",
+ "version": "0.3.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build --webpack",
+ "start": "next start",
+ "lint": "eslint"
+ },
+ "dependencies": {
+ "@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-label": "^2.1.8",
+ "@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-progress": "^1.1.8",
+ "@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-switch": "^1.2.6",
+ "@radix-ui/react-tabs": "^1.1.13",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@tailwindcss/postcss": "^4.1.17",
+ "@upstash/redis": "^1.35.7",
+ "@vercel/analytics": "^1.6.1",
+ "@vercel/speed-insights": "^1.2.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "framer-motion": "^12.23.26",
+ "livekit-client": "^2.16.0",
+ "livekit-server-sdk": "^2.14.2",
+ "lucide-react": "^0.554.0",
+ "next": "16.0.10",
+ "next-themes": "^0.4.6",
+ "react": "19.2.3",
+ "react-diff-viewer-continued": "^3.4.0",
+ "react-dom": "19.2.3",
+ "react-markdown": "^10.1.0",
+ "rehype-highlight": "^7.0.2",
+ "rehype-raw": "^7.0.0",
+ "remark-gfm": "^4.0.1",
+ "sonner": "^2.0.7",
+ "tailwind-merge": "^3.4.0",
+ "tailwindcss": "^4.1.17"
+ },
+ "devDependencies": {
+ "@types/node": "^22",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "autoprefixer": "^10.4.20",
+ "baseline-browser-mapping": "^2.9.4",
+ "eslint": "^9",
+ "eslint-config-next": "16.0.10",
+ "postcss": "^8.4.49",
+ "tailwindcss": "^4.1.17",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "^5"
+ }
+}
\ No newline at end of file
diff --git a/postcss.config.mjs b/postcss.config.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..06a86aa5b13068e49c8aa9613bb35d437ddd694e
--- /dev/null
+++ b/postcss.config.mjs
@@ -0,0 +1,7 @@
+const config = {
+ plugins: {
+ '@tailwindcss/postcss': {},
+ },
+};
+
+export default config;
diff --git a/public/.nojekyll b/public/.nojekyll
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/public/file.svg b/public/file.svg
new file mode 100644
index 0000000000000000000000000000000000000000..004145cddf3f9db91b57b9cb596683c8eb420862
--- /dev/null
+++ b/public/file.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/globe.svg b/public/globe.svg
new file mode 100644
index 0000000000000000000000000000000000000000..567f17b0d7c7fb662c16d4357dd74830caf2dccb
--- /dev/null
+++ b/public/globe.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/logo.png b/public/logo.png
new file mode 100644
index 0000000000000000000000000000000000000000..6a550bb5fb9df9a89508a3daeb25fabbd8afabd0
Binary files /dev/null and b/public/logo.png differ
diff --git a/public/next.svg b/public/next.svg
new file mode 100644
index 0000000000000000000000000000000000000000..5174b28c565c285e3e312ec5178be64fbeca8398
--- /dev/null
+++ b/public/next.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/vercel.svg b/public/vercel.svg
new file mode 100644
index 0000000000000000000000000000000000000000..77053960334e2e34dc584dea8019925c3b4ccca9
--- /dev/null
+++ b/public/vercel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/window.svg b/public/window.svg
new file mode 100644
index 0000000000000000000000000000000000000000..b2b2a44f6ebc70c450043c05a002e7a93ba5d651
--- /dev/null
+++ b/public/window.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/app/admin/agent-monitoring/page.tsx b/src/app/admin/agent-monitoring/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6a356dcc675da9099571164d662bd91e1c8a7a4c
--- /dev/null
+++ b/src/app/admin/agent-monitoring/page.tsx
@@ -0,0 +1,17 @@
+"use client"
+
+import { AgentMonitoring } from "@/components/AgentMonitoring"
+import { AdminSidebar } from "@/components/admin-sidebar"
+
+export default function AgentMonitoringPage() {
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..21f9978eb568860b78ab427179ff5d5edd27a278
--- /dev/null
+++ b/src/app/admin/analytics/page.tsx
@@ -0,0 +1,364 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { useRouter } from "next/navigation"
+import { useAuth } from "@/lib/auth-context"
+import { AdminSidebar } from "@/components/admin-sidebar"
+import { ThemeToggle } from "@/components/theme-toggle"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { toast } from "sonner"
+import {
+ Activity,
+ Users,
+ BarChart3,
+ Loader2,
+ Globe,
+ Key,
+} from "lucide-react"
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+
+interface AnalyticsData {
+ total_users: number
+ total_integrations: number
+ total_api_keys: number
+ total_requests_today: number
+ total_requests_this_week: number
+ total_requests_this_month: number
+ total_cost_today: number
+ total_cost_this_week: number
+ total_cost_this_month: number
+ top_users: Array<{ user_id: string; email: string; request_count: number }>
+ top_integrations: Array<{ integration_id: string; name: string; request_count: number }>
+ top_endpoints: Array<{ endpoint: string; request_count: number }>
+ requests_over_time: Array<{ date: string; count: number }>
+}
+
+function FeedbackTrainingMetrics() {
+ const [metrics, setMetrics] = useState(null)
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ const fetchMetrics = async () => {
+ try {
+ const [feedbackRes, trainingRes, clusterRes] = await Promise.all([
+ fetch("/api/v1/feedback/analytics"),
+ fetch("/api/v1/finetuning/stats"),
+ fetch("/api/v1/clusters/stats")
+ ])
+
+ const data = {
+ feedback: feedbackRes.ok ? await feedbackRes.json() : null,
+ training: trainingRes.ok ? await trainingRes.json() : null,
+ clusters: clusterRes.ok ? await clusterRes.json() : null
+ }
+
+ setMetrics(data)
+ } catch (error) {
+ console.error("Failed to fetch continuous learning metrics:", error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ fetchMetrics()
+ }, [])
+
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ if (!metrics) return null
+
+ return (
+
+
+
+ Total Feedback
+
+
+
+ {metrics.feedback?.feedback_distribution?.total || 0}
+
+
+ {metrics.feedback?.feedback_distribution?.positive_rate?.toFixed(1)}% positive
+
+
+
+
+
+
+ Training Ready
+
+
+
+ {metrics.training?.kept_for_training || 0}
+
+
+ Avg: {metrics.training?.average_score?.toFixed(2) || "N/A"}
+
+
+
+
+
+
+ Pending Export
+
+
+
+ {metrics.training?.awaiting_export || 0}
+
+ Ready for fine-tuning
+
+
+
+
+
+ Task Clusters
+
+
+
+ {metrics.clusters?.active_clusters || 0}
+
+
+ {metrics.clusters?.total_queries_classified || 0} queries classified
+
+
+
+
+ )
+}
+
+
+export default function AdminAnalyticsPage() {
+ const { isAuthenticated, isAdmin, loading } = useAuth()
+ const router = useRouter()
+ const [analytics, setAnalytics] = useState(null)
+ const [loadingAnalytics, setLoadingAnalytics] = useState(true)
+
+ useEffect(() => {
+ if (!loading && !isAuthenticated) {
+ router.push("/auth/signin?redirect=/admin/analytics")
+ } else if (!loading && isAuthenticated && !isAdmin) {
+ router.push("/chat")
+ }
+ }, [isAuthenticated, isAdmin, loading, router])
+
+ useEffect(() => {
+ if (isAuthenticated && isAdmin) {
+ fetchAnalytics()
+ }
+ }, [isAuthenticated, isAdmin])
+
+ const fetchAnalytics = async () => {
+ setLoadingAnalytics(true)
+ try {
+ const sessionToken = localStorage.getItem("session_token")
+ const response = await fetch(`/api/cache/admin/analytics`, {
+ headers: {
+ "X-Session-Token": sessionToken || "",
+ },
+ })
+
+ if (response.ok) {
+ const data = await response.json()
+ setAnalytics(data)
+ const cacheStatus = response.headers.get("X-Cache")
+ if (cacheStatus) console.log(`Analytics cache: ${cacheStatus}`)
+ } else {
+ toast.error("Failed to fetch analytics")
+ }
+ } catch {
+ toast.error("Failed to fetch analytics")
+ } finally {
+ setLoadingAnalytics(false)
+ }
+ }
+
+ if (loading || !isAuthenticated || !isAdmin) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Analytics Dashboard
+
+
System usage and performance metrics
+
+
+ {loadingAnalytics ? (
+
+
+
+ ) : analytics ? (
+ <>
+ {/* AmaniQ Continuous Learning Metrics */}
+
+
AmaniQ Continuous Learning
+
+
+
+ {/* Original System Analytics */}
+ {/* Overview Stats */}
+
+
+
+ Total Users
+
+
+
+ {analytics.total_users}
+
+
+
+
+
+ Integrations
+
+
+
+ {analytics.total_integrations}
+
+
+
+
+
+ API Keys
+
+
+
+ {analytics.total_api_keys}
+
+
+
+
+
+ Requests Today
+
+
+
+ {analytics.total_requests_today}
+
+
+
+
+ {/* Request Stats */}
+
+
+
+ This Week
+
+
+ {analytics.total_requests_this_week}
+ Requests
+
+
+
+
+
+ This Month
+
+
+ {analytics.total_requests_this_month}
+ Requests
+
+
+
+
+
+ Cost This Month
+
+
+
+ ${analytics.total_cost_this_month.toFixed(2)}
+
+ Total cost
+
+
+
+
+ {/* Top Users */}
+ {analytics.top_users && analytics.top_users.length > 0 && (
+
+
+ Top Users
+
+
+
+ {analytics.top_users.slice(0, 10).map((user, index) => (
+
+
+ {index + 1}
+ {user.email}
+
+
+ {user.request_count} requests
+
+
+ ))}
+
+
+
+ )}
+
+ {/* Top Endpoints */}
+ {analytics.top_endpoints && analytics.top_endpoints.length > 0 && (
+
+
+ Top Endpoints
+
+
+
+ {analytics.top_endpoints.slice(0, 10).map((endpoint, index) => (
+
+
+ {index + 1}
+ {endpoint.endpoint}
+
+
+ {endpoint.request_count} requests
+
+
+ ))}
+
+
+
+ )}
+ >
+ ) : (
+
+
+ No analytics data available
+
+
+ )}
+
+
+
+ )
+}
+
diff --git a/src/app/admin/blog/page.tsx b/src/app/admin/blog/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ea766f0165ab72369e5d09b195d8204900861a65
--- /dev/null
+++ b/src/app/admin/blog/page.tsx
@@ -0,0 +1,1007 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { useRouter } from "next/navigation"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
+import { AdminSidebar } from "@/components/admin-sidebar"
+import { ThemeToggle } from "@/components/theme-toggle"
+import { BlogEditor } from "@/components/blog-editor"
+import { useAuth } from "@/lib/auth-context"
+import {
+ Plus,
+ Edit,
+ Trash2,
+ Eye,
+ Save,
+ X,
+ Upload,
+ FileText,
+ Tag,
+ Folder,
+} from "lucide-react"
+import Link from "next/link"
+
+const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+
+const getAuthHeaders = (): Record => {
+ const token = localStorage.getItem("session_token")
+ const headers: Record = {}
+ if (token) {
+ headers["X-Session-Token"] = token
+ }
+ return headers
+}
+
+/**
+ * Removes keys with empty string values from the payload.
+ * This ensures optional fields like slug and featured_image_url
+ * are not sent as empty strings, which would fail backend validation.
+ */
+const cleanPostPayload = (payload: Record): Record => {
+ const cleaned: Record = {}
+ for (const [key, value] of Object.entries(payload)) {
+ // Keep the key if it's not an empty string
+ // Arrays are always included (even if empty)
+ if (value !== "" || Array.isArray(value)) {
+ cleaned[key] = value
+ }
+ }
+ return cleaned
+}
+
+interface BlogPost {
+ id: string
+ title: string
+ slug: string
+ markdown_content?: string
+ html_content?: string
+ excerpt?: string
+ post_type: string
+ status: string
+ featured_image_url?: string
+ author: {
+ id: string
+ name?: string
+ email: string
+ }
+ categories: Array<{ id: string; name: string; slug: string }>
+ tags: Array<{ id: string; name: string; slug: string }>
+ published_at?: string
+ created_at: string
+}
+
+interface Category {
+ id: string
+ name: string
+ slug: string
+ description?: string
+}
+
+interface Tag {
+ id: string
+ name: string
+ slug: string
+}
+
+export default function AdminBlogPage() {
+ const { isAuthenticated, isAdmin, loading } = useAuth()
+ const router = useRouter()
+ const [posts, setPosts] = useState([])
+ const [categories, setCategories] = useState([])
+ const [tags, setTags] = useState([])
+ const [loadingData, setLoadingData] = useState(true)
+ const [activeTab, setActiveTab] = useState<"posts" | "categories" | "tags">("posts")
+ const [editingPost, setEditingPost] = useState(null)
+ const [editingCategory, setEditingCategory] = useState(null)
+ const [editingTag, setEditingTag] = useState(null)
+ const [showPostForm, setShowPostForm] = useState(false)
+ const [postSubmitting, setPostSubmitting] = useState(false) // loading state for create/update
+
+ // Post form state
+ const [postForm, setPostForm] = useState({
+ title: "",
+ slug: "",
+ markdown_content: "",
+ html_content: "",
+ excerpt: "",
+ post_type: "news",
+ status: "draft",
+ featured_image_url: "",
+ category_ids: [] as string[],
+ tag_ids: [] as string[],
+ })
+
+ // Category form state
+ const [categoryForm, setCategoryForm] = useState({
+ name: "",
+ slug: "",
+ description: "",
+ })
+
+ // Tag form state
+ const [tagForm, setTagForm] = useState({
+ name: "",
+ slug: "",
+ })
+
+ useEffect(() => {
+ if (!loading && !isAuthenticated) {
+ router.push("/auth/signin?redirect=/admin/blog")
+ } else if (!loading && isAuthenticated && !isAdmin) {
+ router.push("/chat")
+ }
+ }, [isAuthenticated, isAdmin, loading, router])
+
+ useEffect(() => {
+ if (isAuthenticated && isAdmin) {
+ fetchPosts()
+ fetchCategories()
+ fetchTags()
+ }
+ }, [isAuthenticated, isAdmin])
+
+ const fetchPosts = async () => {
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/posts?page=1&page_size=100`, {
+ headers: getAuthHeaders(),
+ })
+ if (response.ok) {
+ const data = await response.json()
+ setPosts(data.posts || [])
+ }
+ } catch (error) {
+ console.error("Failed to fetch posts:", error)
+ } finally {
+ setLoadingData(false)
+ }
+ }
+
+ const fetchCategories = async () => {
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/categories`)
+ if (response.ok) {
+ const data = await response.json()
+ setCategories(data)
+ }
+ } catch (error) {
+ console.error("Failed to fetch categories:", error)
+ }
+ }
+
+ const fetchTags = async () => {
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/tags`)
+ if (response.ok) {
+ const data = await response.json()
+ setTags(data)
+ }
+ } catch (error) {
+ console.error("Failed to fetch tags:", error)
+ }
+ }
+
+ const handleCreatePost = async () => {
+ setPostSubmitting(true)
+ try {
+ const cleanedPayload = cleanPostPayload(postForm)
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/posts`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...getAuthHeaders(),
+ },
+ body: JSON.stringify(cleanedPayload),
+ })
+ if (response.ok) {
+ await fetchPosts()
+ resetPostForm()
+ setShowPostForm(false)
+ } else {
+ const error = await response.json()
+ console.error('Create post error response:', error)
+ alert(`Failed to create post: ${error.detail || "Unknown error"}`)
+ }
+ } catch (error) {
+ console.error("Failed to create post:", error)
+ alert("Failed to create post")
+ } finally {
+ setPostSubmitting(false)
+ }
+ }
+
+ const handleUpdatePost = async () => {
+ if (!editingPost) return
+ try {
+ const cleanedPayload = cleanPostPayload(postForm)
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/posts/${editingPost.id}`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ ...getAuthHeaders(),
+ },
+ body: JSON.stringify(cleanedPayload),
+ })
+ if (response.ok) {
+ await fetchPosts()
+ setEditingPost(null)
+ resetPostForm()
+ } else {
+ const error = await response.json()
+ alert(`Failed to update post: ${error.detail || "Unknown error"}`)
+ }
+ } catch (error) {
+ console.error("Failed to update post:", error)
+ alert("Failed to update post")
+ }
+ }
+
+ const handleDeletePost = async (postId: string) => {
+ if (!confirm("Are you sure you want to delete this post?")) return
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/posts/${postId}`, {
+ method: "DELETE",
+ headers: getAuthHeaders(),
+ })
+ if (response.ok) {
+ await fetchPosts()
+ } else {
+ alert("Failed to delete post")
+ }
+ } catch (error) {
+ console.error("Failed to delete post:", error)
+ alert("Failed to delete post")
+ }
+ }
+
+ const handleUploadFeaturedImage = async (postId: string, file: File) => {
+ const formData = new FormData()
+ formData.append("file", file)
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/posts/${postId}/featured-image`, {
+ method: "POST",
+ headers: getAuthHeaders(),
+ body: formData,
+ })
+ if (response.ok) {
+ const data = await response.json()
+ setPostForm({ ...postForm, featured_image_url: data.featured_image_url })
+ await fetchPosts()
+ } else {
+ alert("Failed to upload image")
+ }
+ } catch (error) {
+ console.error("Failed to upload image:", error)
+ alert("Failed to upload image")
+ }
+ }
+
+ const resetPostForm = () => {
+ setPostForm({
+ title: "",
+ slug: "",
+ markdown_content: "",
+ html_content: "",
+ excerpt: "",
+ post_type: "news",
+ status: "draft",
+ featured_image_url: "",
+ category_ids: [],
+ tag_ids: [],
+ })
+ }
+
+ const startEditPost = (post: BlogPost) => {
+ setEditingPost(post)
+ setPostForm({
+ title: post.title,
+ slug: post.slug,
+ markdown_content: post.markdown_content || "",
+ html_content: post.html_content || "",
+ excerpt: post.excerpt || "",
+ post_type: post.post_type,
+ status: post.status,
+ featured_image_url: post.featured_image_url || "",
+ category_ids: post.categories.map((c) => c.id),
+ tag_ids: post.tags.map((t) => t.id),
+ })
+ setShowPostForm(true)
+ }
+
+ const handleCreateCategory = async () => {
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/categories`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...getAuthHeaders(),
+ },
+ body: JSON.stringify(categoryForm),
+ })
+ if (response.ok) {
+ await fetchCategories()
+ setCategoryForm({ name: "", slug: "", description: "" })
+ setEditingCategory(null)
+ } else {
+ const error = await response.json()
+ alert(`Failed to create category: ${error.detail || "Unknown error"}`)
+ }
+ } catch (error) {
+ console.error("Failed to create category:", error)
+ alert("Failed to create category")
+ }
+ }
+
+ const handleUpdateCategory = async () => {
+ if (!editingCategory) return
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/categories/${editingCategory.id}`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ ...getAuthHeaders(),
+ },
+ body: JSON.stringify(categoryForm),
+ })
+ if (response.ok) {
+ await fetchCategories()
+ setEditingCategory(null)
+ setCategoryForm({ name: "", slug: "", description: "" })
+ } else {
+ const error = await response.json()
+ alert(`Failed to update category: ${error.detail || "Unknown error"}`)
+ }
+ } catch (error) {
+ console.error("Failed to update category:", error)
+ alert("Failed to update category")
+ }
+ }
+
+ const handleDeleteCategory = async (categoryId: string) => {
+ if (!confirm("Are you sure you want to delete this category?")) return
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/categories/${categoryId}`, {
+ method: "DELETE",
+ headers: getAuthHeaders(),
+ })
+ if (response.ok) {
+ await fetchCategories()
+ } else {
+ alert("Failed to delete category")
+ }
+ } catch (error) {
+ console.error("Failed to delete category:", error)
+ alert("Failed to delete category")
+ }
+ }
+
+ const handleCreateTag = async () => {
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/tags`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...getAuthHeaders(),
+ },
+ body: JSON.stringify(tagForm),
+ })
+ if (response.ok) {
+ await fetchTags()
+ setTagForm({ name: "", slug: "" })
+ setEditingTag(null)
+ } else {
+ const error = await response.json()
+ alert(`Failed to create tag: ${error.detail || "Unknown error"}`)
+ }
+ } catch (error) {
+ console.error("Failed to create tag:", error)
+ alert("Failed to create tag")
+ }
+ }
+
+ const handleUpdateTag = async () => {
+ if (!editingTag) return
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/tags/${editingTag.id}`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ ...getAuthHeaders(),
+ },
+ body: JSON.stringify(tagForm),
+ })
+ if (response.ok) {
+ await fetchTags()
+ setEditingTag(null)
+ setTagForm({ name: "", slug: "" })
+ } else {
+ const error = await response.json()
+ alert(`Failed to update tag: ${error.detail || "Unknown error"}`)
+ }
+ } catch (error) {
+ console.error("Failed to update tag:", error)
+ alert("Failed to update tag")
+ }
+ }
+
+ const handleDeleteTag = async (tagId: string) => {
+ if (!confirm("Are you sure you want to delete this tag?")) return
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/tags/${tagId}`, {
+ method: "DELETE",
+ headers: getAuthHeaders(),
+ })
+ if (response.ok) {
+ await fetchTags()
+ } else {
+ alert("Failed to delete tag")
+ }
+ } catch (error) {
+ console.error("Failed to delete tag:", error)
+ alert("Failed to delete tag")
+ }
+ }
+
+ if (loading || !isAuthenticated || !isAdmin) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
Blog Management
+
Manage blog posts, categories, and tags
+
+
+
+
+ View Public Blog
+
+
+
+
+ {/* Tabs */}
+
+ setActiveTab("posts")}
+ className={`px-4 py-2 font-medium ${
+ activeTab === "posts"
+ ? "border-b-2 border-primary text-primary"
+ : "text-muted-foreground"
+ }`}
+ >
+
+ Posts
+
+ setActiveTab("categories")}
+ className={`px-4 py-2 font-medium ${
+ activeTab === "categories"
+ ? "border-b-2 border-primary text-primary"
+ : "text-muted-foreground"
+ }`}
+ >
+
+ Categories
+
+ setActiveTab("tags")}
+ className={`px-4 py-2 font-medium ${
+ activeTab === "tags"
+ ? "border-b-2 border-primary text-primary"
+ : "text-muted-foreground"
+ }`}
+ >
+
+ Tags
+
+
+
+ {/* Posts Tab */}
+ {activeTab === "posts" && (
+
+ {!showPostForm ? (
+ <>
+
+
setShowPostForm(true)}>
+
+ New Post
+
+
+
+
+
+
+
+ Title
+ Type
+ Status
+ Author
+ Published
+ Actions
+
+
+
+ {posts.map((post) => (
+
+ {post.title}
+
+ {post.post_type}
+
+
+
+ {post.status}
+
+
+ {post.author.name || post.author.email}
+
+ {post.published_at
+ ? new Date(post.published_at).toLocaleDateString()
+ : "-"}
+
+
+
+ startEditPost(post)}
+ >
+
+
+
+
+
+
+
+ handleDeletePost(post.id)}
+ >
+
+
+
+
+
+ ))}
+
+
+
+
+ >
+ ) : (
+
+
+
+
+ {editingPost ? "Edit Post" : "Create New Post"}
+
+ {
+ setShowPostForm(false)
+ setEditingPost(null)
+ resetPostForm()
+ }}>
+
+
+
+
+
+
+
+
+ Post Type
+ setPostForm({ ...postForm, post_type: e.target.value })}
+ className="w-full px-3 py-2 border rounded-md"
+ >
+ News
+ Announcement
+ Update
+
+
+
+ Status
+ setPostForm({ ...postForm, status: e.target.value })}
+ className="w-full px-3 py-2 border rounded-md"
+ >
+ Draft
+ Published
+
+
+
+
+ Excerpt
+
+
+
+ setPostForm({ ...postForm, markdown_content: value })
+ }
+ onHtmlChange={(value) =>
+ setPostForm({ ...postForm, html_content: value })
+ }
+ />
+
+
Categories
+
+ {categories.map((category) => (
+ {
+ const newIds = postForm.category_ids.includes(category.id)
+ ? postForm.category_ids.filter((id) => id !== category.id)
+ : [...postForm.category_ids, category.id]
+ setPostForm({ ...postForm, category_ids: newIds })
+ }}
+ >
+ {category.name}
+
+ ))}
+
+
+
+
Tags
+
+ {tags.map((tag) => (
+ {
+ const newIds = postForm.tag_ids.includes(tag.id)
+ ? postForm.tag_ids.filter((id) => id !== tag.id)
+ : [...postForm.tag_ids, tag.id]
+ setPostForm({ ...postForm, tag_ids: newIds })
+ }}
+ >
+ {tag.name}
+
+ ))}
+
+
+
+
{
+ setShowPostForm(false)
+ setEditingPost(null)
+ resetPostForm()
+ }}
+ >
+ Cancel
+
+
+ {postSubmitting ? (
+
+
+
+
+
+ {editingPost ? "Updating..." : "Creating..."}
+
+ ) : (
+ <>
+
+ {editingPost ? "Update" : "Create"} Post
+ >
+ )}
+
+
+
+
+ )}
+
+ )}
+
+ {/* Categories Tab */}
+ {activeTab === "categories" && (
+
+
+
{
+ setEditingCategory(null)
+ setCategoryForm({ name: "", slug: "", description: "" })
+ }}
+ >
+
+ New Category
+
+
+ {(editingCategory || (!editingCategory && categoryForm.name)) && (
+
+
+
+ {editingCategory ? "Edit Category" : "Create Category"}
+
+
+
+
+ Name
+ setCategoryForm({ ...categoryForm, name: e.target.value })}
+ placeholder="Category name"
+ />
+
+
+ Slug
+ setCategoryForm({ ...categoryForm, slug: e.target.value })}
+ placeholder="category-slug"
+ />
+
+
+ Description
+
+
+ {
+ setEditingCategory(null)
+ setCategoryForm({ name: "", slug: "", description: "" })
+ }}
+ >
+ Cancel
+
+
+
+ {editingCategory ? "Update" : "Create"}
+
+
+
+
+ )}
+
+
+
+
+
+ Name
+ Slug
+ Description
+ Actions
+
+
+
+ {categories.map((category) => (
+
+ {category.name}
+ {category.slug}
+ {category.description || "-"}
+
+
+ {
+ setEditingCategory(category)
+ setCategoryForm({
+ name: category.name,
+ slug: category.slug,
+ description: category.description || "",
+ })
+ }}
+ >
+
+
+ handleDeleteCategory(category.id)}
+ >
+
+
+
+
+
+ ))}
+
+
+
+
+
+ )}
+
+ {/* Tags Tab */}
+ {activeTab === "tags" && (
+
+
+
{
+ setEditingTag(null)
+ setTagForm({ name: "", slug: "" })
+ }}
+ >
+
+ New Tag
+
+
+ {(editingTag || (!editingTag && tagForm.name)) && (
+
+
+ {editingTag ? "Edit Tag" : "Create Tag"}
+
+
+
+ Name
+ setTagForm({ ...tagForm, name: e.target.value })}
+ placeholder="Tag name"
+ />
+
+
+ Slug
+ setTagForm({ ...tagForm, slug: e.target.value })}
+ placeholder="tag-slug"
+ />
+
+
+ {
+ setEditingTag(null)
+ setTagForm({ name: "", slug: "" })
+ }}
+ >
+ Cancel
+
+
+
+ {editingTag ? "Update" : "Create"}
+
+
+
+
+ )}
+
+
+
+
+
+ Name
+ Slug
+ Actions
+
+
+
+ {tags.map((tag) => (
+
+ {tag.name}
+ {tag.slug}
+
+
+ {
+ setEditingTag(tag)
+ setTagForm({ name: tag.name, slug: tag.slug })
+ }}
+ >
+
+
+ handleDeleteTag(tag.id)}
+ >
+
+
+
+
+
+ ))}
+
+
+
+
+
+ )}
+
+
+
+ )
+}
+
diff --git a/src/app/admin/knowledge/page.tsx b/src/app/admin/knowledge/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..598b7357a837f8563cb598938de0b7fe5bcd853b
--- /dev/null
+++ b/src/app/admin/knowledge/page.tsx
@@ -0,0 +1,279 @@
+"use client"
+
+/**
+ * Knowledge Base Settings - Admin Page
+ *
+ * WeKnora-style knowledge base management for admins
+ */
+
+import { useState } from "react"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Database,
+ Plus,
+ Upload,
+ RefreshCw,
+ Trash2,
+ Edit,
+ FileText,
+ Scale,
+ Newspaper,
+ CheckCircle,
+ XCircle,
+ Clock,
+ Settings,
+ Search
+} from "lucide-react"
+
+
+interface KnowledgeBase {
+ id: string
+ name: string
+ description: string
+ type: 'legal' | 'news' | 'general' | 'custom'
+ documentCount: number
+ lastUpdated: string
+ status: 'active' | 'indexing' | 'error'
+ chunkSize: number
+ embeddingModel: string
+}
+
+const MOCK_KNOWLEDGE_BASES: KnowledgeBase[] = [
+ {
+ id: "kenya_law",
+ name: "Kenya Law",
+ description: "Constitution, Acts, Bills, Case Law from Kenya Law Reports",
+ type: "legal",
+ documentCount: 5420,
+ lastUpdated: "2025-12-14T10:30:00Z",
+ status: "active",
+ chunkSize: 512,
+ embeddingModel: "text-embedding-3-small"
+ },
+ {
+ id: "kenya_news",
+ name: "Kenya News",
+ description: "Current affairs from major Kenyan news outlets",
+ type: "news",
+ documentCount: 12350,
+ lastUpdated: "2025-12-15T08:00:00Z",
+ status: "active",
+ chunkSize: 256,
+ embeddingModel: "text-embedding-3-small"
+ },
+ {
+ id: "parliament",
+ name: "Parliament Records",
+ description: "Bills, Hansard, Committee Reports",
+ type: "legal",
+ documentCount: 2100,
+ lastUpdated: "2025-12-13T14:20:00Z",
+ status: "indexing",
+ chunkSize: 512,
+ embeddingModel: "text-embedding-3-small"
+ }
+]
+
+export default function KnowledgeBaseSettingsPage() {
+ const [knowledgeBases, setKnowledgeBases] = useState(MOCK_KNOWLEDGE_BASES)
+ const [selectedKB, setSelectedKB] = useState(null)
+ const [isCreating, setIsCreating] = useState(false)
+
+ const getTypeIcon = (type: string) => {
+ switch (type) {
+ case 'legal': return
+ case 'news': return
+ default: return
+ }
+ }
+
+ const getStatusBadge = (status: string) => {
+ switch (status) {
+ case 'active':
+ return Active
+ case 'indexing':
+ return Indexing
+ case 'error':
+ return Error
+ default:
+ return {status}
+ }
+ }
+
+ return (
+
+
+
+
Knowledge Base Management
+
Manage your document collections and search indices
+
+
+
+
+
+ New Knowledge Base
+
+
+
+
+ Create Knowledge Base
+
+ Create a new knowledge base for document retrieval
+
+
+
+
+
+ Name
+
+
+
+ Type
+
+ Legal
+ News
+ General
+ Custom
+
+
+
+
+ Description
+
+
+
+
+ Chunk Size
+
+
+
+ Embedding Model
+
+ text-embedding-3-small
+ text-embedding-3-large
+ text-embedding-ada-002
+
+
+
+
+
+ setIsCreating(false)}>Cancel
+ setIsCreating(false)}>Create
+
+
+
+
+
+
+
+
+ Total Knowledge Bases
+
+
+ {knowledgeBases.length}
+
+
+
+
+ Total Documents
+
+
+
+ {knowledgeBases.reduce((sum, kb) => sum + kb.documentCount, 0).toLocaleString()}
+
+
+
+
+
+ Active Indices
+
+
+
+ {knowledgeBases.filter(kb => kb.status === 'active').length}
+
+
+
+
+
+
+
+ Knowledge Bases
+ Manage document collections and search indices
+
+
+
+
+
+ Name
+ Type
+ Documents
+ Status
+ Last Updated
+ Actions
+
+
+
+ {knowledgeBases.map((kb) => (
+
+
+
+ {getTypeIcon(kb.type)}
+
+
{kb.name}
+
{kb.description}
+
+
+
+
+ {kb.type}
+
+ {kb.documentCount.toLocaleString()}
+ {getStatusBadge(kb.status)}
+ {new Date(kb.lastUpdated).toLocaleDateString()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+ )
+}
diff --git a/src/app/admin/models/page.tsx b/src/app/admin/models/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2aa4627d2c95796c6998f05fec3396c64fad0946
--- /dev/null
+++ b/src/app/admin/models/page.tsx
@@ -0,0 +1,327 @@
+"use client"
+
+/**
+ * Model Settings - Admin Page
+ *
+ * WeKnora-style AI model configuration for admins
+ */
+
+import { useState, useEffect, ReactNode } from "react"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Switch } from "@/components/ui/switch"
+import { Slider } from "@/components/ui/slider"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Brain,
+ Sparkles,
+ Zap,
+ Rocket,
+ Plus,
+ Settings,
+ CheckCircle,
+ XCircle,
+ Key,
+ RefreshCw,
+ Loader2
+} from "lucide-react"
+
+interface ModelConfig {
+ apiKey?: string
+ maxTokens?: number
+ topP?: number
+ isDefault?: boolean
+ id: string
+ name: string
+ provider: string
+ enabled: boolean
+ is_default?: boolean
+ status: string
+ temperature: number
+ max_tokens?: number
+ top_p?: number
+}
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+
+export default function ModelSettingsPage() {
+ const [models, setModels] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [selectedModel, setSelectedModel] = useState(null)
+
+ useEffect(() => {
+ fetchModels()
+ }, [])
+
+ const fetchModels = async () => {
+ try {
+ setLoading(true)
+ const response = await fetch(`${API_URL}/api/v1/admin/models`)
+ if (!response.ok) throw new Error("Failed to fetch models")
+ const data = await response.json()
+ setModels(data)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load models")
+ // Fallback to default models
+ setModels([
+ {
+ id: "moonshot-v1-8k", name: "Moonshot V1", provider: "Moonshot", enabled: true, is_default: true, status: "connected", temperature: 0.7, max_tokens: 4096,
+ apiKey: undefined,
+ maxTokens: undefined,
+ topP: undefined,
+ isDefault: undefined
+ },
+ {
+ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", provider: "Google", enabled: true, is_default: false, status: "connected", temperature: 0.7, max_tokens: 4096,
+ apiKey: undefined,
+ maxTokens: undefined,
+ topP: undefined,
+ isDefault: undefined
+ },
+ {
+ id: "gemini-1.5-pro", name: "Gemini 1.5 Pro", provider: "Google", enabled: true, is_default: false, status: "connected", temperature: 0.7, max_tokens: 8192,
+ apiKey: undefined,
+ maxTokens: undefined,
+ topP: undefined,
+ isDefault: undefined
+ },
+ {
+ id: "gpt-4o-mini", name: "GPT-4o Mini", provider: "OpenAI", enabled: false, is_default: false, status: "unconfigured", temperature: 0.7, max_tokens: 4096,
+ apiKey: undefined,
+ maxTokens: undefined,
+ topP: undefined,
+ isDefault: undefined
+ },
+ {
+ id: "claude-3.5-sonnet", name: "Claude 3.5 Sonnet", provider: "Anthropic", enabled: false, is_default: false, status: "unconfigured", temperature: 0.7, max_tokens: 4096,
+ apiKey: undefined,
+ maxTokens: undefined,
+ topP: undefined,
+ isDefault: undefined
+ }
+ ])
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const getProviderIcon = (provider: string) => {
+ switch (provider) {
+ case 'Google': return
+ case 'Moonshot': return
+ case 'OpenAI': return
+ case 'Anthropic': return
+ default: return
+ }
+ }
+
+ const getStatusBadge = (status: string) => {
+ switch (status) {
+ case 'connected':
+ return Connected
+ case 'error':
+ return Error
+ case 'unconfigured':
+ return Unconfigured
+ default:
+ return {status}
+ }
+ }
+
+ const updateModel = (id: string, updates: Partial) => {
+ setModels(prev => prev.map(m => m.id === id ? { ...m, ...updates } : m))
+ }
+
+ const setAsDefault = (id: string) => {
+ setModels(prev => prev.map(m => ({ ...m, isDefault: m.id === id })))
+ }
+
+ return (
+
+
+
+
Model Settings
+
Configure AI models and their parameters
+
+
+
+ Test All Connections
+
+
+
+
+
+
+ Configured Models
+
+
+ {models.filter(m => m.status === 'connected').length}
+
+
+
+
+ Enabled Models
+
+
+ {models.filter(m => m.enabled).length}
+
+
+
+
+ Default Model
+
+
+ {models.find(m => m.isDefault)?.name || 'None'}
+
+
+
+
+ Providers
+
+
+ {new Set(models.map(m => m.provider)).size}
+
+
+
+
+
+
+ AI Models
+ Configure model access and parameters
+
+
+
+
+
+ Model
+ Provider
+ Status
+ Enabled
+ Default
+ Actions
+
+
+
+ {models.map((model) => (
+
+
+
+ {getProviderIcon(model.provider)}
+
+
{model.name}
+
{model.id}
+
+
+
+
+ {model.provider}
+
+ {getStatusBadge(model.status)}
+
+ updateModel(model.id, { enabled: checked })}
+ disabled={model.status !== 'connected'}
+ />
+
+
+ setAsDefault(model.id)}
+ disabled={!model.enabled}
+ />
+
+
+
+
+
+ setSelectedModel(model)}
+ >
+
+
+
+
+
+ Configure {model.name}
+
+ Adjust model parameters and API settings
+
+
+
+
+ API Key
+
+
+
+ Temperature: {model.temperature}
+ updateModel(model.id, { temperature: v[0] })}
+ />
+
+
+ Max Tokens: {model.maxTokens ?? model.max_tokens ?? 4096}
+ updateModel(model.id, { maxTokens: v[0] })}
+ />
+
+
+ Top P: {model.topP ?? model.top_p ?? 0.7}
+ updateModel(model.id, { topP: v[0] })}
+ />
+
+
+
+ Save Changes
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+ )
+}
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..52cc6d8b8f13a49f7b491d09212bc7e482a2f1f7
--- /dev/null
+++ b/src/app/admin/page.tsx
@@ -0,0 +1,1144 @@
+"use client"
+
+import React, { useState, useEffect, useCallback, useRef } from "react"
+import { useRouter } from "next/navigation"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
+import { Input } from "@/components/ui/input"
+import { AdminSidebar } from "@/components/admin-sidebar"
+import { ThemeToggle } from "@/components/theme-toggle"
+import { useAuth } from "@/lib/auth-context"
+import {
+ Database,
+ Activity,
+ Clock,
+ CheckCircle,
+ XCircle,
+ Play,
+ Search,
+ BarChart3,
+ FileText,
+ Globe,
+ RefreshCw,
+ Square
+} from "lucide-react"
+import Link from "next/link"
+
+const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+
+// Helper function to get auth headers
+const getAuthHeaders = (): Record => {
+ const token = localStorage.getItem("session_token")
+ const headers: Record = {}
+ if (token) {
+ headers["X-Session-Token"] = token
+ }
+ return headers
+}
+
+interface Stats {
+ total_chunks: number
+ categories: Record
+ sources: string[]
+}
+
+interface Health {
+ status: string
+ database_chunks: number
+ embedding_model: string
+ llm_provider: string
+}
+
+interface Crawler {
+ status: "idle" | "running" | "failed"
+ last_run: string | null
+ logs: string[]
+ pid?: number
+ start_time?: string
+}
+interface CommandResult {
+ command: string
+ exit_code: number
+ stdout: string
+ stderr: string
+}
+
+interface Document {
+ id: string
+ content: string
+ metadata: {
+ title: string
+ url: string
+ source: string
+ category: string
+ date: string
+ author?: string
+ sentiment_polarity?: number
+ sentiment_label?: string
+ }
+ score: number
+}
+
+interface Database {
+ name: string
+ type: string
+ status: string
+ total_chunks: number
+ categories: Record
+ persist_directory: string
+ elasticsearch_docs: number
+ elasticsearch_enabled: boolean
+}
+
+interface DatabaseStorageStats {
+ raw_documents: {
+ total: number
+ processed: number
+ unprocessed: number
+ categories: Record
+ }
+ processed_chunks: {
+ total: number
+ categories: Record
+ }
+}
+
+interface ConfigData {
+ [key: string]: {
+ has_value: boolean
+ description?: string
+ }
+}
+
+interface DatabaseStats {
+ total_databases: number
+ active_databases: number
+ databases: Database[]
+}
+
+export default function AdminDashboard() {
+ const { isAuthenticated, isAdmin, loading } = useAuth()
+ const router = useRouter()
+ const [stats, setStats] = useState(null)
+ const [health, setHealth] = useState(null)
+ const [crawlers, setCrawlers] = useState>({})
+ const [documents, setDocuments] = useState([])
+ const [searchQuery, setSearchQuery] = useState("")
+ const [command, setCommand] = useState("")
+ const [commandHistory, setCommandHistory] = useState([])
+ const [isExecuting, setIsExecuting] = useState(false)
+ const [configs, setConfigs] = useState({})
+ const [newConfigKey, setNewConfigKey] = useState("")
+ const [newConfigValue, setNewConfigValue] = useState("")
+ const [newConfigDescription, setNewConfigDescription] = useState("")
+ const [databaseStats, setDatabaseStats] = useState(null)
+ const [databaseStorageStats, setDatabaseStorageStats] = useState(null)
+ const [selectedCrawler, setSelectedCrawler] = useState(null)
+ const [isRefreshing, setIsRefreshing] = useState(false)
+ const isRefreshingRef = useRef(false)
+ const intervalRef = useRef(null)
+ const fetchCrawlersRef = useRef<(() => Promise) | null>(null)
+ const intervalSetupRef = useRef(false)
+
+ useEffect(() => {
+ if (!loading && !isAuthenticated) {
+ router.push("/auth/signin?redirect=/admin")
+ } else if (!loading && isAuthenticated && !isAdmin) {
+ router.push("/chat")
+ }
+ }, [isAuthenticated, isAdmin, loading, router])
+
+ const fetchStats = async () => {
+ try {
+ const response = await fetch(`/api/cache/admin/stats`, {
+ headers: getAuthHeaders()
+ })
+ if (response.ok) {
+ const data = await response.json()
+ setStats(data)
+ }
+ } catch (error) {
+ console.error("Failed to fetch stats:", error)
+ }
+ }
+
+ const fetchHealth = async () => {
+ try {
+ const response = await fetch(`${API_BASE_URL}/health`)
+ if (response.ok) {
+ const data = await response.json()
+ setHealth(data)
+ }
+ } catch (error) {
+ console.error("Failed to fetch health:", error)
+ }
+ }
+
+ // Create a stable fetch function that uses refs for auth state
+ const isAuthenticatedRef = useRef(isAuthenticated)
+ const isAdminRef = useRef(isAdmin)
+
+ // Update refs when values change
+ useEffect(() => {
+ isAuthenticatedRef.current = isAuthenticated
+ isAdminRef.current = isAdmin
+ }, [isAuthenticated, isAdmin])
+
+ const fetchCrawlers = useCallback(async () => {
+ // Only fetch if authenticated and admin (check refs for latest values)
+ if (!isAuthenticatedRef.current || !isAdminRef.current) {
+ return
+ }
+
+ // Prevent concurrent fetches
+ if (isRefreshingRef.current) {
+ return
+ }
+
+ try {
+ isRefreshingRef.current = true
+ setIsRefreshing(true)
+ const response = await fetch(`${API_BASE_URL}/api/admin/crawlers`, {
+ headers: {
+ "Content-Type": "application/json",
+ ...getAuthHeaders()
+ },
+ })
+ if (response.ok) {
+ const data = await response.json()
+ setCrawlers(data.crawlers || {})
+ } else {
+ console.error("Failed to fetch crawlers:", response.status)
+ setCrawlers({})
+ }
+ } catch (error) {
+ console.error("Failed to fetch crawlers:", error)
+ setCrawlers({})
+ } finally {
+ isRefreshingRef.current = false
+ setIsRefreshing(false)
+ }
+ }, []) // No dependencies - uses refs for auth state
+
+ // Update the ref whenever fetchCrawlers changes (though it shouldn't change now)
+ useEffect(() => {
+ fetchCrawlersRef.current = fetchCrawlers
+ }, [fetchCrawlers])
+
+ const fetchConfigs = async () => {
+ try {
+ const response = await fetch(`/api/cache/admin/config`, {
+ headers: {
+ "Content-Type": "application/json",
+ ...getAuthHeaders()
+ },
+ })
+ if (response.ok) {
+ const data = await response.json()
+ setConfigs(data)
+ }
+ } catch (error) {
+ console.error("Failed to fetch configs:", error)
+ }
+ }
+
+ const fetchDatabaseStats = async () => {
+ try {
+ const response = await fetch(`/api/cache/admin/databases`, {
+ headers: {
+ "Content-Type": "application/json",
+ ...getAuthHeaders()
+ },
+ })
+ if (response.ok) {
+ const data = await response.json()
+ setDatabaseStats(data)
+ }
+ } catch (error) {
+ console.error("Failed to fetch database stats:", error)
+ }
+ }
+
+ const fetchDatabaseStorageStats = async () => {
+ try {
+ const response = await fetch(`/api/cache/admin/database-storage`, {
+ headers: {
+ "Content-Type": "application/json",
+ ...getAuthHeaders()
+ },
+ })
+ if (response.ok) {
+ const data = await response.json()
+ setDatabaseStorageStats(data)
+ }
+ } catch (error) {
+ console.error("Failed to fetch database storage stats:", error)
+ }
+ }
+
+ const searchDocuments = async () => {
+ try {
+ const params = new URLSearchParams()
+ if (searchQuery) params.append("query", searchQuery)
+ params.append("limit", "100")
+
+ const response = await fetch(`/api/cache/admin/documents?${params}`, {
+ headers: {
+ "Content-Type": "application/json",
+ ...getAuthHeaders()
+ },
+ })
+ if (response.ok) {
+ const data = await response.json()
+ setDocuments(data.documents)
+ }
+ } catch (error) {
+ console.error("Failed to search documents:", error)
+ }
+ }
+
+ const executeCommand = async () => {
+ if (!command.trim()) return
+
+ setIsExecuting(true)
+ try {
+ const response = await fetch(`${API_BASE_URL}/admin/execute`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...getAuthHeaders()
+ },
+ body: JSON.stringify({ command }),
+ })
+
+ if (response.ok) {
+ const result: CommandResult = await response.json()
+ setCommandHistory(prev => [result, ...prev.slice(0, 9)]) // Keep last 10
+ setCommand("")
+ }
+ } catch (error) {
+ console.error("Failed to execute command:", error)
+ } finally {
+ setIsExecuting(false)
+ }
+ }
+
+ // Initial data fetch - only runs when auth state stabilizes
+ useEffect(() => {
+ // Only fetch data if authenticated and admin
+ if (!isAuthenticated || !isAdmin || loading) {
+ return
+ }
+
+ fetchStats()
+ fetchHealth()
+ fetchCrawlers() // Now stable, can be called directly
+ fetchConfigs()
+ fetchDatabaseStats()
+ fetchDatabaseStorageStats()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isAuthenticated, isAdmin, loading])
+
+ // Separate effect for interval - only runs once when component mounts and auth is ready
+ useEffect(() => {
+ // Only set up interval if authenticated and admin
+ if (!isAuthenticated || !isAdmin || loading) {
+ // Clear interval if auth state changes
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current)
+ intervalRef.current = null
+ intervalSetupRef.current = false
+ }
+ return
+ }
+
+ // Prevent multiple interval setups
+ if (intervalSetupRef.current && intervalRef.current) {
+ return
+ }
+
+ // Clear any existing interval before creating a new one (safety check)
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current)
+ intervalRef.current = null
+ }
+
+ // Auto-refresh crawler status every 10 seconds
+ intervalRef.current = setInterval(() => {
+ // Use the ref to get the latest fetchCrawlers function
+ if (fetchCrawlersRef.current) {
+ fetchCrawlersRef.current()
+ }
+ }, 10000)
+
+ intervalSetupRef.current = true
+
+ return () => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current)
+ intervalRef.current = null
+ intervalSetupRef.current = false
+ }
+ }
+ }, [isAuthenticated, isAdmin, loading])
+
+ const runCrawler = async (crawlerName: string) => {
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/admin/crawlers/${crawlerName}/start`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...getAuthHeaders()
+ },
+ })
+
+ if (response.ok) {
+ const result = await response.json().catch(() => ({}))
+ // Refresh crawler status after a short delay
+ setTimeout(() => {
+ fetchCrawlers()
+ }, 1000)
+ console.log("Crawler started successfully:", result)
+ } else {
+ let errorMessage = `Failed to start crawler: ${response.status} ${response.statusText}`
+ try {
+ const errorData = await response.json()
+ errorMessage = errorData.detail || errorData.message || errorMessage
+ } catch {
+ // If JSON parsing fails, use the default message
+ }
+ console.error("Failed to start crawler:", errorMessage)
+ alert(errorMessage)
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error)
+ console.error("Failed to start crawler:", errorMessage)
+ alert(`Failed to start crawler: ${errorMessage}`)
+ }
+ }
+
+ const stopCrawler = async (crawlerName: string) => {
+ try {
+ const response = await fetch(`${API_BASE_URL}/admin/crawlers/${crawlerName}/stop`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...getAuthHeaders()
+ },
+ })
+ if (response.ok) {
+ setTimeout(() => {
+ fetchCrawlers()
+ }, 1000)
+ } else {
+ let errorMessage = `Failed to stop crawler: ${response.status} ${response.statusText}`
+ try {
+ const errorData = await response.json()
+ errorMessage = errorData.detail || errorData.message || errorMessage
+ } catch {
+ // If JSON parsing fails, use the default message
+ }
+ console.error("Failed to stop crawler:", errorMessage)
+ alert(errorMessage)
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : String(error)
+ console.error("Failed to stop crawler:", errorMessage)
+ alert(`Failed to stop crawler: ${errorMessage}`)
+ }
+ }
+
+ const viewCrawlerLogs = (crawlerName: string) => {
+ setSelectedCrawler(selectedCrawler === crawlerName ? null : crawlerName)
+ }
+
+ const setConfig = async () => {
+ if (!newConfigKey.trim() || !newConfigValue.trim()) return
+
+ try {
+ const response = await fetch(`${API_BASE_URL}/admin/config`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...getAuthHeaders()
+ },
+ body: JSON.stringify({
+ key: newConfigKey,
+ value: newConfigValue,
+ description: newConfigDescription
+ }),
+ })
+ if (response.ok) {
+ // Invalidate cache
+ await fetch(`/api/cache/admin/config`, { method: "DELETE" })
+
+ setNewConfigKey("")
+ setNewConfigValue("")
+ setNewConfigDescription("")
+ fetchConfigs()
+ }
+ } catch (error) {
+ console.error("Failed to set config:", error)
+ }
+ }
+
+ const deleteConfig = async (key: string) => {
+ try {
+ const response = await fetch(`${API_BASE_URL}/admin/config/${key}`, {
+ method: "DELETE",
+ headers: {
+ "Content-Type": "application/json",
+ ...getAuthHeaders()
+ },
+ })
+ if (response.ok) {
+ // Invalidate cache
+ await fetch(`/api/cache/admin/config`, { method: "DELETE" })
+ fetchConfigs()
+ }
+ } catch (error) {
+ console.error("Failed to delete config:", error)
+ }
+ }
+
+ const getStatusColor = (status: string) => {
+ switch (status) {
+ case "healthy": return "text-green-600"
+ case "running": return "text-blue-600"
+ case "failed": return "text-red-600"
+ default: return "text-gray-600"
+ }
+ }
+
+ const getStatusIcon = (status: string) => {
+ switch (status) {
+ case "healthy": return
+ case "running": return
+ case "failed": return
+ default: return
+ }
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (!isAuthenticated || !isAdmin) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+
+ {/* Header */}
+
+
+
Admin Dashboard
+
Control Panel for AmaniQuery System
+
+
+
+
+ Back to Home
+
+
+
+
+ {/* Stats Cards */}
+
+
+
+ Total Documents
+
+
+
+
+ {stats?.total_chunks || health?.database_chunks || 0}
+
+
+ Vector database chunks
+
+
+
+
+
+
+ Vector DB Status
+
+
+
+
+ {getStatusIcon(health?.status || "unknown")}
+
+ {health?.status || "Unknown"}
+
+
+
+ {health?.embedding_model || "Loading..."}
+
+
+
+
+
+
+ System Status
+
+
+
+
+ Active
+
+
+ System operational
+
+
+
+
+
+
+ API Health
+
+
+
+
+ {health ? (
+ <>
+
+ Online
+ >
+ ) : (
+ <>
+
+ Offline
+ >
+ )}
+
+
+ {health?.llm_provider || "Checking..."}
+
+
+
+
+
+ {/* Crawler Management */}
+
+
+
+
+
+ Crawler Management
+
+
+
+ {isRefreshing ? "Refreshing..." : "Refresh"}
+
+
+
+
+
+
+
+
+ Crawler
+ Status
+ Last Run
+ PID
+ Actions
+
+
+
+ {crawlers && Object.keys(crawlers).length > 0 ? (
+ Object.entries(crawlers).map(([name, crawler]) => (
+
+
+
+ {name.replace(/_/g, ' ')}
+
+
+
+ {crawler.status}
+
+
+
+ {crawler.last_run ? new Date(crawler.last_run).toLocaleString() : "Never"}
+
+
+ {crawler.pid || "-"}
+
+
+
+
runCrawler(name)}
+ disabled={crawler.status === "running"}
+ className="h-8 px-2 md:px-3 text-xs md:text-sm min-w-[60px]"
+ >
+
+ Run
+
+ {crawler.status === "running" && (
+
stopCrawler(name)}
+ className="h-8 px-2 md:px-3 text-xs md:text-sm min-w-[60px]"
+ >
+
+ Stop
+
+ )}
+
viewCrawlerLogs(name)}
+ className="h-8 px-2 md:px-3 text-xs md:text-sm"
+ >
+ {selectedCrawler === name ? "Hide" : "Logs"}
+
+
+
+
+ {selectedCrawler === name && crawler.logs && crawler.logs.length > 0 && (
+
+
+
+
+ {crawler.logs.slice(-20).map((log, idx) => (
+
+ {log}
+
+ ))}
+
+
+
+
+ )}
+
+ ))
+ ) : (
+
+
+ No crawlers available or loading...
+
+
+ )}
+
+
+
+
+
+
+ {/* Database Management */}
+
+
+
+
+ Database Management
+
+
+
+
+
+
+
+ {databaseStats?.total_databases || 0}
+
+
Total Databases
+
+
+
+ {databaseStats?.active_databases || 0}
+
+
Active Databases
+
+
+
+ {databaseStats?.databases?.reduce((sum, db) => sum + db.total_chunks, 0) || 0}
+
+
Total Chunks
+
+
+
+
+ {databaseStats?.databases?.map((db) => (
+
+
+
+
+
+
+
{db.name}
+
{db.type} Database
+
+
+
+ {db.status}
+
+
+
+
+
+
Chunks:
+
{db.total_chunks.toLocaleString()}
+
+ {db.elasticsearch_enabled && (
+
+
ES Docs:
+
{db.elasticsearch_docs.toLocaleString()}
+
+ )}
+
+
Categories:
+
{Object.keys(db.categories).length}
+
+ {db.persist_directory && (
+
+
Storage:
+
+ {db.persist_directory.split('/').pop() || 'Local'}
+
+
+ )}
+
+
+ {Object.keys(db.categories).length > 0 && (
+
+
Top Categories:
+
+ {Object.entries(db.categories)
+ .sort(([,a], [,b]) => b - a)
+ .slice(0, 5)
+ .map(([category, count]) => (
+
+ {category}: {count}
+
+ ))}
+
+
+ )}
+
+
+ ))}
+
+
+
+
+
+ {/* Command Shell */}
+
+
+
+
+ Command Shell
+
+
+
+
+
+ setCommand(e.target.value)}
+ onKeyPress={(e) => e.key === 'Enter' && executeCommand()}
+ className="flex-1 font-mono text-sm md:text-base h-10 md:h-11"
+ disabled={isExecuting}
+ />
+
+ {isExecuting ? "Running..." : "Execute"}
+
+
+
+
+ {commandHistory.map((result, index) => (
+
+
+
+ $
+ {result.command}
+
+ Exit: {result.exit_code}
+
+
+ {result.stdout && (
+
+ {result.stdout}
+
+ )}
+ {result.stderr && (
+
+ {result.stderr}
+
+ )}
+
+
+ ))}
+
+
+
+
+
+ {/* Data Explorer */}
+
+
+
+
+ Data Explorer
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ onKeyPress={(e) => e.key === 'Enter' && searchDocuments()}
+ className="flex-1 h-10 md:h-11 text-sm md:text-base"
+ />
+
+
+ Search
+
+
+
+ {documents.length > 0 && (
+
+
+ Found {documents.length} documents
+
+
+ {documents.slice(0, 20).map((doc) => (
+
+
+
+
+ {doc.metadata.title || "Untitled"}
+
+
+ {doc.metadata.category}
+
+
+
+ {doc.content.substring(0, 200)}...
+
+
+ {doc.metadata.source}
+ {doc.metadata.date}
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
Categories: {stats ? Object.keys(stats.categories).join(", ") : "Loading..."}
+
Sources: {stats ? stats.sources.join(", ") : "Loading..."}
+
+
+
+
+
+
+ {/* Database Storage */}
+
+
+
+
+ Database Storage
+
+
+
+
+ {databaseStorageStats ? (
+ <>
+
+
+
+ {databaseStorageStats.raw_documents.total}
+
+
Raw Documents
+
+ {databaseStorageStats.raw_documents.processed} processed
+
+
+ {databaseStorageStats.raw_documents.unprocessed} unprocessed
+
+
+
+
+ {databaseStorageStats.processed_chunks.total}
+
+
Processed Chunks
+
+
+
+
+
+
Raw Documents by Category
+
+ {Object.entries(databaseStorageStats.raw_documents.categories).map(([category, count]) => (
+
+ {category}
+ {count}
+
+ ))}
+
+
+
+
Processed Chunks by Category
+
+ {Object.entries(databaseStorageStats.processed_chunks.categories).map(([category, count]) => (
+
+ {category}
+ {count}
+
+ ))}
+
+
+
+ >
+ ) : (
+
+ Database storage stats not available
+
+ )}
+
+
+
+
+ {/* Configuration Management */}
+
+
+
+
+ Configuration Management
+
+
+
+
+ {/* Add New Config */}
+
+
+ {/* Config List */}
+
+
Current Configurations
+
+ {Object.entries(configs).map(([key, config]) => (
+
+
+
+
+
{key}
+
{config.description || "No description"}
+
+
+
+ {config.has_value ? "Set" : "Empty"}
+
+ deleteConfig(key)}
+ className="h-8 px-2 text-xs"
+ >
+ Delete
+
+
+
+
+
+ ))}
+ {Object.keys(configs).length === 0 && (
+
+ No configurations found
+
+ )}
+
+
+
+ {/* Common Config Templates */}
+
+
Common Configuration Keys:
+
+
• ELASTICSEARCH_URL - Elasticsearch cloud URL
+
• ELASTICSEARCH_API_KEY - Elasticsearch API key
+
• UPSTASH_VECTOR_URL - Upstash Vector URL
+
• UPSTASH_VECTOR_TOKEN - Upstash Vector token
+
• QDRANT_URL - QDrant cloud URL
+
• QDRANT_API_KEY - QDrant API key
+
• UPSTASH_REDIS_URL - Upstash Redis URL
+
• UPSTASH_REDIS_TOKEN - Upstash Redis token
+
• LLM_PROVIDER - LLM provider (moonshot, openai, etc.)
+
• GEMINI_API_KEY - Gemini API key
+
• OPENAI_API_KEY - OpenAI API key
+
+
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/app/admin/rag/page.tsx b/src/app/admin/rag/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..24a1a45dca67866a195b7b410f35302b79657938
--- /dev/null
+++ b/src/app/admin/rag/page.tsx
@@ -0,0 +1,344 @@
+"use client"
+
+/**
+ * RAG Settings - Admin Page
+ *
+ * Configure retrieval-augmented generation pipeline settings
+ */
+
+import { useState } from "react"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Switch } from "@/components/ui/switch"
+import { Slider } from "@/components/ui/slider"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import {
+ Search,
+ Zap,
+ Filter,
+ Sparkles,
+ RefreshCw,
+ Database,
+ TrendingUp,
+ Settings,
+ CheckCircle
+} from "lucide-react"
+
+interface RAGConfig {
+ // Retrieval settings
+ topK: number
+ useReranking: boolean
+ rerankTopK: number
+
+ // Query expansion
+ useHyDE: boolean
+ useMultiQuery: boolean
+ queryVariants: number
+
+ // Hybrid search
+ useHybridSearch: boolean
+ hybridAlpha: number // 0 = all BM25, 1 = all vector
+
+ // Caching
+ enableSemanticCache: boolean
+ cacheTTLHours: number
+
+ // Performance
+ parallelRetrieval: boolean
+ maxNamespaces: number
+}
+
+const DEFAULT_CONFIG: RAGConfig = {
+ topK: 5,
+ useReranking: true,
+ rerankTopK: 5,
+ useHyDE: false,
+ useMultiQuery: false,
+ queryVariants: 3,
+ useHybridSearch: false,
+ hybridAlpha: 0.7,
+ enableSemanticCache: true,
+ cacheTTLHours: 24,
+ parallelRetrieval: true,
+ maxNamespaces: 5
+}
+
+export default function RAGSettingsPage() {
+ const [config, setConfig] = useState(DEFAULT_CONFIG)
+ const [isSaving, setIsSaving] = useState(false)
+
+ const updateConfig = (key: K, value: RAGConfig[K]) => {
+ setConfig(prev => ({ ...prev, [key]: value }))
+ }
+
+ const handleSave = async () => {
+ setIsSaving(true)
+ // TODO: Save to backend
+ await new Promise(r => setTimeout(r, 1000))
+ setIsSaving(false)
+ }
+
+ return (
+
+
+
+
RAG Pipeline Settings
+
Configure retrieval and generation parameters
+
+
+ {isSaving ? : }
+ Save Changes
+
+
+
+
+
+
+ Top K
+
+
+ {config.topK}
+ Documents retrieved
+
+
+
+
+ Re-ranking
+
+
+ {config.useReranking ? "Enabled" : "Disabled"}
+ Intelligent filtering
+
+
+
+
+ Hybrid α
+
+
+ {config.hybridAlpha.toFixed(1)}
+ Vector vs BM25 balance
+
+
+
+
+ Cache TTL
+
+
+ {config.cacheTTLHours}h
+ Result caching
+
+
+
+
+
+
+
+
+ Retrieval
+
+
+
+ Query Expansion
+
+
+
+ Hybrid Search
+
+
+
+ Performance
+
+
+
+
+
+
+ Retrieval Settings
+ Configure document retrieval parameters
+
+
+
+
Top K: {config.topK}
+
updateConfig('topK', v[0])}
+ />
+ Number of documents to retrieve
+
+
+
+
+
Intelligent Re-ranking
+
Use cross-encoder to filter relevant docs
+
+
updateConfig('useReranking', v)}
+ />
+
+
+ {config.useReranking && (
+
+
Re-rank Top K: {config.rerankTopK}
+
updateConfig('rerankTopK', v[0])}
+ />
+ Documents after re-ranking
+
+ )}
+
+
+
+
+
+
+
+ Query Expansion
+ Improve recall with query expansion techniques
+
+
+
+
+
HyDE (Hypothetical Document Embeddings)
+
Generate hypothetical answer, then search
+
+
updateConfig('useHyDE', v)}
+ />
+
+
+
+
+
Multi-Query
+
Generate multiple query variants
+
+
updateConfig('useMultiQuery', v)}
+ />
+
+
+ {config.useMultiQuery && (
+
+ Query Variants: {config.queryVariants}
+ updateConfig('queryVariants', v[0])}
+ />
+
+ )}
+
+
+
+
+
+
+
+ Hybrid Search
+ Combine vector and keyword search
+
+
+
+
+
Enable Hybrid Search
+
Combine BM25 + vector similarity
+
+
updateConfig('useHybridSearch', v)}
+ />
+
+
+ {config.useHybridSearch && (
+
+
Hybrid Alpha: {config.hybridAlpha.toFixed(2)}
+
updateConfig('hybridAlpha', v[0])}
+ />
+
+ BM25 (keywords)
+ Vector (semantic)
+
+
+ )}
+
+
+
+
+
+
+
+ Performance Settings
+ Optimize speed and caching
+
+
+
+
+
Parallel Retrieval
+
Query all namespaces simultaneously
+
+
updateConfig('parallelRetrieval', v)}
+ />
+
+
+
+ Max Namespaces: {config.maxNamespaces}
+ updateConfig('maxNamespaces', v[0])}
+ />
+
+
+
+
+
Semantic Cache
+
Cache similar query results
+
+
updateConfig('enableSemanticCache', v)}
+ />
+
+
+ {config.enableSemanticCache && (
+
+ Cache TTL: {config.cacheTTLHours} hours
+ updateConfig('cacheTTLHours', v[0])}
+ />
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f8a3ca9c393d7ba561d53e119675342fc97ba84a
--- /dev/null
+++ b/src/app/admin/settings/page.tsx
@@ -0,0 +1,303 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { useRouter } from "next/navigation"
+import { useAuth } from "@/lib/auth-context"
+import { AdminSidebar } from "@/components/admin-sidebar"
+import { ThemeToggle } from "@/components/theme-toggle"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Badge } from "@/components/ui/badge"
+import { toast } from "sonner"
+import {
+ Settings,
+ Plus,
+ Trash2,
+ Loader2,
+ RefreshCw,
+} from "lucide-react"
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+
+interface ConfigEntry {
+ key: string
+ value: string
+ description?: string
+}
+
+export default function AdminSettingsPage() {
+ const { isAuthenticated, isAdmin, loading } = useAuth()
+ const router = useRouter()
+ const [configs, setConfigs] = useState>({})
+ const [loadingConfigs, setLoadingConfigs] = useState(true)
+ const [newConfig, setNewConfig] = useState({
+ key: "",
+ value: "",
+ description: "",
+ })
+ const [saving, setSaving] = useState(false)
+
+ useEffect(() => {
+ if (!loading && !isAuthenticated) {
+ router.push("/auth/signin?redirect=/admin/settings")
+ } else if (!loading && isAuthenticated && !isAdmin) {
+ router.push("/chat")
+ }
+ }, [isAuthenticated, isAdmin, loading, router])
+
+ useEffect(() => {
+ if (isAuthenticated && isAdmin) {
+ fetchConfigs()
+ }
+ }, [isAuthenticated, isAdmin])
+
+ const fetchConfigs = async () => {
+ setLoadingConfigs(true)
+ try {
+ const sessionToken = localStorage.getItem("session_token")
+ const response = await fetch(`${API_URL}/admin/config`, {
+ headers: {
+ "X-Session-Token": sessionToken || "",
+ },
+ })
+ if (response.ok) {
+ const data = await response.json()
+ setConfigs(data.configs || {})
+ } else {
+ toast.error("Failed to fetch settings")
+ }
+ } catch {
+ toast.error("Failed to fetch settings")
+ } finally {
+ setLoadingConfigs(false)
+ }
+ }
+
+ const handleSaveConfig = async (key: string, value: string) => {
+ try {
+ const sessionToken = localStorage.getItem("session_token")
+ const response = await fetch(`${API_URL}/admin/config/${key}`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Session-Token": sessionToken || "",
+ },
+ body: JSON.stringify({ value }),
+ })
+
+ if (response.ok) {
+ toast.success("Setting updated successfully")
+ fetchConfigs()
+ } else {
+ toast.error("Failed to update setting")
+ }
+ } catch {
+ toast.error("Failed to update setting")
+ }
+ }
+
+ const handleAddConfig = async () => {
+ if (!newConfig.key || !newConfig.value) {
+ toast.error("Key and value are required")
+ return
+ }
+
+ setSaving(true)
+ try {
+ const sessionToken = localStorage.getItem("session_token")
+ const response = await fetch(`${API_URL}/admin/config/${newConfig.key}`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Session-Token": sessionToken || "",
+ },
+ body: JSON.stringify({
+ value: newConfig.value,
+ description: newConfig.description,
+ }),
+ })
+
+ if (response.ok) {
+ toast.success("Setting added successfully")
+ setNewConfig({ key: "", value: "", description: "" })
+ fetchConfigs()
+ } else {
+ toast.error("Failed to add setting")
+ }
+ } catch {
+ toast.error("Failed to add setting")
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleDeleteConfig = async (key: string) => {
+ if (!confirm(`Are you sure you want to delete the setting "${key}"?`)) {
+ return
+ }
+
+ try {
+ const sessionToken = localStorage.getItem("session_token")
+ const response = await fetch(`${API_URL}/admin/config/${key}`, {
+ method: "DELETE",
+ headers: {
+ "X-Session-Token": sessionToken || "",
+ },
+ })
+
+ if (response.ok) {
+ toast.success("Setting deleted successfully")
+ fetchConfigs()
+ } else {
+ toast.error("Failed to delete setting")
+ }
+ } catch {
+ toast.error("Failed to delete setting")
+ }
+ }
+
+ if (loading || !isAuthenticated || !isAdmin) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Site Settings
+
+
Manage system configuration and settings
+
+
+ {/* Add New Config */}
+
+
+ Add New Setting
+ Create a new configuration entry
+
+
+
+
+
+
+ {/* Existing Configs */}
+
+
+
+
+ System Settings
+ Manage existing configuration entries
+
+
+
+ Refresh
+
+
+
+
+ {loadingConfigs ? (
+
+
+
+ ) : Object.keys(configs).length === 0 ? (
+
+ No settings configured
+
+ ) : (
+
+ {Object.entries(configs).map(([key, config]) => (
+
+
+
+ {key}
+ {config.description && (
+
+ {config.description}
+
+ )}
+
+
{
+ setConfigs({
+ ...configs,
+ [key]: { ...config, value: e.target.value },
+ })
+ }}
+ onBlur={() => handleSaveConfig(key, config.value)}
+ className="font-mono text-sm"
+ />
+
+
handleDeleteConfig(key)}
+ className="text-destructive hover:text-destructive"
+ >
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ )
+}
+
diff --git a/src/app/admin/training/page.tsx b/src/app/admin/training/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d7d0c79668e4943155de23c668158fc09ab8582e
--- /dev/null
+++ b/src/app/admin/training/page.tsx
@@ -0,0 +1,273 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Database, Download, RefreshCw } from "lucide-react";
+import { AdminSidebar } from "@/components/admin-sidebar";
+
+interface FeedbackAnalytics {
+ feedback_distribution: {
+ total: number;
+ positive: number;
+ negative: number;
+ positive_rate: number;
+ };
+ training_impact: {
+ total_scored: number;
+ training_candidates: number;
+ awaiting_export: number;
+ conversion_rate: string;
+ average_quality_score: number;
+ };
+ score_distribution: {
+ [key: string]: number;
+ };
+}
+
+interface TrainingStats {
+ total_scored: number;
+ kept_for_training: number;
+ exported: number;
+ awaiting_export: number;
+ average_score: number;
+ score_distribution: {
+ [key: string]: number;
+ };
+}
+
+interface ClusterStats {
+ total_clusters: number;
+ active_clusters: number;
+ pending_suggestions: number;
+ total_queries_classified: number;
+}
+
+export default function TrainingMonitorPage() {
+ const [feedbackData, setFeedbackData] = useState(null);
+ const [trainingData, setTrainingData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+
+ const fetchAnalytics = async () => {
+ try {
+ setRefreshing(true);
+
+ const feedbackRes = await fetch("/api/v1/feedback/analytics");
+ if (feedbackRes.ok) {
+ setFeedbackData(await feedbackRes.json());
+ }
+
+ const trainingRes = await fetch("/api/v1/finetuning/stats");
+ if (trainingRes.ok) {
+ setTrainingData(await trainingRes.json());
+ }
+ } catch (error) {
+ console.error("Failed to fetch analytics:", error);
+ } finally {
+ setLoading(false);
+ setRefreshing(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchAnalytics();
+
+ // Auto-refresh every 30 seconds for real-time monitoring
+ const interval = setInterval(() => {
+ fetchAnalytics();
+ }, 30000);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ const handleExport = async (format: string) => {
+ try {
+ const res = await fetch(`/api/v1/finetuning/export?format=${format}&min_score=4.0`, {
+ method: "POST",
+ });
+ const data = await res.json();
+ alert(`Exported ${data.exported_count} interactions!`);
+ fetchAnalytics(); // Refresh stats
+ } catch (error) {
+ console.error("Export failed:", error);
+ alert("Export failed. Check console.");
+ }
+ };
+
+ const scoreLast7Days = async () => {
+ try {
+ const res = await fetch("/api/v1/finetuning/auto-score-all?days=7", { method: "POST" });
+ const data = await res.json();
+ alert(`Scored ${data.scored} interactions, ${data.saved_for_training} added to training dataset!`);
+ fetchAnalytics();
+ } catch (error) {
+ console.error("Scoring failed:", error);
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+
+
Loading training metrics...
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+
+
Training & Feedback Monitor
+
Track model improvement and data quality
+
+
+
+ Score Last 7 Days
+
+
+
+ Refresh
+
+
+
+
+ {/* KPI Cards */}
+
+
+
+ Total Feedback
+
+
+ {feedbackData?.feedback_distribution.total || 0}
+
+ ↑ {feedbackData?.feedback_distribution.positive_rate.toFixed(1)}% positive
+
+
+
+
+
+
+ Training Ready
+
+
+ {feedbackData?.training_impact.training_candidates || 0}
+
+ Score: {feedbackData?.training_impact.average_quality_score.toFixed(2) || "N/A"}
+
+
+
+
+
+
+ Awaiting Export
+
+
+ {feedbackData?.training_impact.awaiting_export || 0}
+ Ready to fine-tune
+
+
+
+
+
+ Conversion Rate
+
+
+ {feedbackData?.training_impact.conversion_rate || "0%"}
+ Feedback → Training
+
+
+
+
+ {/* Export Section */}
+
+
+
+
+ Export Training Dataset
+
+
+ Export {feedbackData?.training_impact.awaiting_export || 0} high-quality interactions for fine-tuning
+
+
+
+
+ handleExport("alpaca")} className="flex-1" size="lg">
+
+ Export Alpaca Format
+
+ handleExport("sharegpt")} variant="secondary" className="flex-1" size="lg">
+
+ Export ShareGPT Format
+
+
+
+
+
+ {/* Quality Distribution */}
+
+
+
+ Quality Score Distribution
+
+
+
+ {feedbackData?.score_distribution && Object.entries(feedbackData.score_distribution).map(([tier, count]) => {
+ const percentage = (count / (feedbackData.training_impact.total_scored || 1)) * 100;
+ return (
+
+
+ {tier.replace(/_/g, " ")}
+ {count}
+
+
+
+ );
+ })}
+
+
+
+
+
+
+ Training Pipeline Status
+
+
+
+
+ Total Interactions Scored
+ {trainingData?.total_scored || 0}
+
+
+ Approved for Training
+ {trainingData?.kept_for_training || 0}
+
+
+ Already Exported
+ {trainingData?.exported || 0}
+
+
+ Pending Export
+ {trainingData?.awaiting_export || 0}
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..fb008a185750a6f03cd0a6d2057004e2f503b105
--- /dev/null
+++ b/src/app/admin/users/page.tsx
@@ -0,0 +1,711 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { useRouter } from "next/navigation"
+import { useAuth } from "@/lib/auth-context"
+import { AdminSidebar } from "@/components/admin-sidebar"
+import { ThemeToggle } from "@/components/theme-toggle"
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Badge } from "@/components/ui/badge"
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Label } from "@/components/ui/label"
+import { toast } from "sonner"
+import {
+ Users,
+ Search,
+ CheckCircle,
+ XCircle,
+ ChevronLeft,
+ ChevronRight,
+ Loader2,
+ MoreVertical,
+ Edit,
+ Trash2,
+ Ban,
+ CheckCheck,
+ ShieldOff,
+ ShieldCheck,
+ Activity,
+ Filter,
+} from "lucide-react"
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+
+interface User {
+ id: string
+ email: string
+ name: string | null
+ status: string
+ email_verified: boolean
+ last_login: string | null
+ created_at: string
+ updated_at: string
+ roles?: string[]
+}
+
+interface AdminStats {
+ total_users: number
+ active_users: number
+ suspended_users: number
+ verified_users: number
+ unverified_users: number
+}
+
+export default function AdminUsersPage() {
+ const { isAuthenticated, isAdmin, loading } = useAuth()
+ const router = useRouter()
+ const [users, setUsers] = useState([])
+ const [stats, setStats] = useState(null)
+ const [loadingUsers, setLoadingUsers] = useState(true)
+ const [searchQuery, setSearchQuery] = useState("")
+ const [statusFilter, setStatusFilter] = useState("all")
+ const [verificationFilter, setVerificationFilter] = useState("all")
+ const [page, setPage] = useState(1)
+ const [total, setTotal] = useState(0)
+ const [pageSize] = useState(20)
+
+ // Dialog states
+ const [editDialogOpen, setEditDialogOpen] = useState(false)
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
+ const [selectedUser, setSelectedUser] = useState(null)
+ const [editFormData, setEditFormData] = useState({ name: "", email: "", status: "" })
+ const [actionLoading, setActionLoading] = useState(false)
+
+ useEffect(() => {
+ if (!loading && !isAuthenticated) {
+ router.push("/auth/signin?redirect=/admin/users")
+ } else if (!loading && isAuthenticated && !isAdmin) {
+ router.push("/chat")
+ }
+ }, [isAuthenticated, isAdmin, loading, router])
+
+ useEffect(() => {
+ if (isAuthenticated && isAdmin) {
+ fetchUsers()
+ fetchStats()
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [page, statusFilter, verificationFilter, isAuthenticated, isAdmin])
+
+ const fetchStats = async () => {
+ try {
+ const sessionToken = localStorage.getItem("session_token")
+ const response = await fetch(`${API_URL}/api/v1/auth/admin/stats`, {
+ headers: {
+ "X-Session-Token": sessionToken || "",
+ },
+ })
+ if (response.ok) {
+ const data = await response.json()
+ setStats(data)
+ }
+ } catch {
+ // Stats are optional, don't show error
+ }
+ }
+
+ const fetchUsers = async () => {
+ setLoadingUsers(true)
+ try {
+ const sessionToken = localStorage.getItem("session_token")
+ const params = new URLSearchParams({
+ page: page.toString(),
+ page_size: pageSize.toString(),
+ })
+
+ if (searchQuery) params.append("search", searchQuery)
+ if (statusFilter !== "all") params.append("status", statusFilter)
+ if (verificationFilter !== "all") params.append("email_verified", verificationFilter)
+
+ const response = await fetch(
+ `${API_URL}/api/v1/auth/admin/users?${params}`,
+ {
+ headers: {
+ "X-Session-Token": sessionToken || "",
+ },
+ }
+ )
+
+ if (response.ok) {
+ const data = await response.json()
+ setUsers(data.users || [])
+ setTotal(data.total || 0)
+ } else {
+ toast.error("Failed to fetch users")
+ }
+ } catch {
+ toast.error("Failed to fetch users")
+ } finally {
+ setLoadingUsers(false)
+ }
+ }
+
+ const handleEditUser = (user: User) => {
+ setSelectedUser(user)
+ setEditFormData({
+ name: user.name || "",
+ email: user.email,
+ status: user.status,
+ })
+ setEditDialogOpen(true)
+ }
+
+ const handleSaveEdit = async () => {
+ if (!selectedUser) return
+
+ setActionLoading(true)
+ try {
+ const sessionToken = localStorage.getItem("session_token")
+ const response = await fetch(
+ `${API_URL}/api/v1/auth/admin/users/${selectedUser.id}`,
+ {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Session-Token": sessionToken || "",
+ },
+ body: JSON.stringify({
+ name: editFormData.name || null,
+ email: editFormData.email,
+ status: editFormData.status,
+ }),
+ }
+ )
+
+ if (response.ok) {
+ toast.success("User updated successfully")
+ setEditDialogOpen(false)
+ fetchUsers()
+ fetchStats()
+ } else {
+ const error = await response.json()
+ toast.error(error.detail || "Failed to update user")
+ }
+ } catch {
+ toast.error("Failed to update user")
+ } finally {
+ setActionLoading(false)
+ }
+ }
+
+ const handleVerifyEmail = async (userId: string, verify: boolean) => {
+ try {
+ const sessionToken = localStorage.getItem("session_token")
+ const endpoint = verify ? "verify-email" : "unverify-email"
+ const response = await fetch(
+ `${API_URL}/api/v1/auth/admin/users/${userId}/${endpoint}`,
+ {
+ method: "POST",
+ headers: {
+ "X-Session-Token": sessionToken || "",
+ },
+ }
+ )
+
+ if (response.ok) {
+ toast.success(verify ? "Email verified" : "Email unverified")
+ fetchUsers()
+ fetchStats()
+ } else {
+ toast.error("Failed to update verification status")
+ }
+ } catch {
+ toast.error("Failed to update verification status")
+ }
+ }
+
+ const handleSuspendUser = async (userId: string, suspend: boolean) => {
+ try {
+ const sessionToken = localStorage.getItem("session_token")
+ const endpoint = suspend ? "suspend" : "activate"
+ const response = await fetch(
+ `${API_URL}/api/v1/auth/admin/users/${userId}/${endpoint}`,
+ {
+ method: "POST",
+ headers: {
+ "X-Session-Token": sessionToken || "",
+ },
+ }
+ )
+
+ if (response.ok) {
+ toast.success(suspend ? "User suspended" : "User activated")
+ fetchUsers()
+ fetchStats()
+ } else {
+ toast.error("Failed to update user status")
+ }
+ } catch {
+ toast.error("Failed to update user status")
+ }
+ }
+
+ const handleDeleteUser = async () => {
+ if (!selectedUser) return
+
+ setActionLoading(true)
+ try {
+ const sessionToken = localStorage.getItem("session_token")
+ const response = await fetch(
+ `${API_URL}/api/v1/auth/admin/users/${selectedUser.id}`,
+ {
+ method: "DELETE",
+ headers: {
+ "X-Session-Token": sessionToken || "",
+ },
+ }
+ )
+
+ if (response.ok) {
+ toast.success("User deleted successfully")
+ setDeleteDialogOpen(false)
+ fetchUsers()
+ fetchStats()
+ } else {
+ const error = await response.json()
+ toast.error(error.detail || "Failed to delete user")
+ }
+ } catch {
+ toast.error("Failed to delete user")
+ } finally {
+ setActionLoading(false)
+ }
+ }
+
+ const handleRevokeSession = async (userId: string) => {
+ try {
+ const sessionToken = localStorage.getItem("session_token")
+ const response = await fetch(
+ `${API_URL}/api/v1/auth/admin/users/${userId}/revoke-sessions`,
+ {
+ method: "POST",
+ headers: {
+ "X-Session-Token": sessionToken || "",
+ },
+ }
+ )
+
+ if (response.ok) {
+ const data = await response.json()
+ toast.success(`Revoked ${data.sessions_revoked} session(s)`)
+ } else {
+ toast.error("Failed to revoke sessions")
+ }
+ } catch {
+ toast.error("Failed to revoke sessions")
+ }
+ }
+
+
+ const filteredUsers = users.filter(
+ (user) =>
+ user.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ (user.name && user.name.toLowerCase().includes(searchQuery.toLowerCase()))
+ )
+
+ const totalPages = Math.ceil(total / pageSize)
+
+ if (loading || !isAuthenticated || !isAdmin) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ User Management
+
+
Manage all users in the system
+
+
+ {/* Search and Filters */}
+
+
+
+
+ Filters & Search
+
+
+ {stats && (
+
+
+
Total Users
+
{stats.total_users}
+
+
+
Active
+
{stats.active_users}
+
+
+
Suspended
+
{stats.suspended_users}
+
+
+
Verified
+
{stats.verified_users}
+
+
+ )}
+
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && fetchUsers()}
+ className="pl-10"
+ />
+
+
+
+
+
+
+ All Statuses
+ Active
+ Inactive
+ Suspended
+
+
+
+
+
+
+
+ All Users
+ Verified Only
+ Unverified Only
+
+
+
+
+
+
+ {/* Users Table */}
+
+
+ All Users
+
+
+ {loadingUsers ? (
+
+
+
+ ) : (
+ <>
+
+
+
+
+ Email
+ Name
+ Status
+ Verified
+ Roles
+ Last Login
+ Actions
+
+
+
+ {filteredUsers.length === 0 ? (
+
+
+ No users found
+
+
+ ) : (
+ filteredUsers.map((user) => (
+
+ {user.email}
+ {user.name || "—"}
+
+
+ {user.status}
+
+
+
+ {user.email_verified ? (
+
+ ) : (
+
+ )}
+
+
+ {user.roles && user.roles.length > 0 ? (
+
+ {user.roles.map((role) => (
+
+ {role}
+
+ ))}
+
+ ) : (
+ —
+ )}
+
+
+ {user.last_login
+ ? new Date(user.last_login).toLocaleDateString()
+ : "Never"}
+
+
+
+
+
+
+
+
+
+ User Actions
+
+ handleEditUser(user)}>
+
+ Edit User
+
+ handleVerifyEmail(user.id, !user.email_verified)}>
+ {user.email_verified ? (
+ <>
+
+ Unverify Email
+ >
+ ) : (
+ <>
+
+ Verify Email
+ >
+ )}
+
+ handleSuspendUser(user.id, user.status !== "suspended")}>
+ {user.status === "suspended" ? (
+ <>
+
+ Activate Account
+ >
+ ) : (
+ <>
+
+ Suspend Account
+ >
+ )}
+
+ handleRevokeSession(user.id)}>
+
+ Revoke Sessions
+
+
+ {
+ setSelectedUser(user)
+ setDeleteDialogOpen(true)
+ }}
+ className="text-red-600"
+ >
+
+ Delete User
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+
+ Page {page} of {totalPages}
+
+
+ setPage((p) => Math.max(1, p - 1))}
+ disabled={page === 1}
+ >
+
+ Previous
+
+ setPage((p) => Math.min(totalPages, p + 1))}
+ disabled={page === totalPages}
+ >
+ Next
+
+
+
+
+ )}
+ >
+ )}
+
+
+
+
+
+ {/* Edit User Dialog */}
+
+
+
+ Edit User
+
+ Update user information and account settings.
+
+
+
+
+ Name
+ setEditFormData({ ...editFormData, name: e.target.value })}
+ placeholder="User name"
+ />
+
+
+ Email
+ setEditFormData({ ...editFormData, email: e.target.value })}
+ placeholder="user@example.com"
+ />
+
+
+ Status
+ setEditFormData({ ...editFormData, status: value })}
+ >
+
+
+
+
+ Active
+ Inactive
+ Suspended
+
+
+
+
+
+ setEditDialogOpen(false)} disabled={actionLoading}>
+ Cancel
+
+
+ {actionLoading ? (
+ <>
+
+ Saving...
+ >
+ ) : (
+ "Save Changes"
+ )}
+
+
+
+
+
+ {/* Delete User Dialog */}
+
+
+
+ Delete User
+
+ Are you sure you want to permanently delete this user? This action cannot be undone.
+
+
+ {selectedUser && (
+
+
+
+ Email: {selectedUser.email}
+
+
+ Name: {selectedUser.name || "—"}
+
+
+ Status: {selectedUser.status}
+
+
+
+ ⚠️ All user data, sessions, and associated records will be permanently deleted.
+
+
+ )}
+
+ setDeleteDialogOpen(false)} disabled={actionLoading}>
+ Cancel
+
+
+ {actionLoading ? (
+ <>
+
+ Deleting...
+ >
+ ) : (
+ <>
+
+ Delete User
+ >
+ )}
+
+
+
+
+
+ )
+}
+
diff --git a/src/app/api/admin/agent-metrics/route.ts b/src/app/api/admin/agent-metrics/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7440d420a5e55f8fdb244c0ac3360e46a93a498a
--- /dev/null
+++ b/src/app/api/admin/agent-metrics/route.ts
@@ -0,0 +1,37 @@
+import { NextRequest, NextResponse } from "next/server"
+
+const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+
+export async function GET(request: NextRequest) {
+ try {
+ const sessionToken = request.headers.get("x-session-token")
+
+ const headers: Record = {
+ "Content-Type": "application/json",
+ }
+
+ if (sessionToken) {
+ headers["X-Session-Token"] = sessionToken
+ }
+
+ const response = await fetch(`${API_BASE_URL}/api/admin/agent-metrics`, {
+ headers,
+ })
+
+ if (!response.ok) {
+ return NextResponse.json(
+ { error: "Failed to fetch agent metrics" },
+ { status: response.status }
+ )
+ }
+
+ const data = await response.json()
+ return NextResponse.json(data)
+ } catch (error) {
+ console.error("Error fetching agent metrics:", error)
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/app/api/admin/query-logs/route.ts b/src/app/api/admin/query-logs/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3c9aee1c18f7dd1036c68b81c18b13914fdb1f5e
--- /dev/null
+++ b/src/app/api/admin/query-logs/route.ts
@@ -0,0 +1,41 @@
+import { NextRequest, NextResponse } from "next/server"
+
+const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+
+export async function GET(request: NextRequest) {
+ try {
+ const sessionToken = request.headers.get("x-session-token")
+ const searchParams = request.nextUrl.searchParams
+ const limit = searchParams.get("limit") || "100"
+ const offset = searchParams.get("offset") || "0"
+
+ const headers: Record = {
+ "Content-Type": "application/json",
+ }
+
+ if (sessionToken) {
+ headers["X-Session-Token"] = sessionToken
+ }
+
+ const response = await fetch(
+ `${API_BASE_URL}/api/admin/query-logs?limit=${limit}&offset=${offset}`,
+ { headers }
+ )
+
+ if (!response.ok) {
+ return NextResponse.json(
+ { error: "Failed to fetch query logs" },
+ { status: response.status }
+ )
+ }
+
+ const data = await response.json()
+ return NextResponse.json(data)
+ } catch (error) {
+ console.error("Error fetching query logs:", error)
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/app/api/admin/retrain/route.ts b/src/app/api/admin/retrain/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ed1495d33f6d043ab756c593bf30f816307584d1
--- /dev/null
+++ b/src/app/api/admin/retrain/route.ts
@@ -0,0 +1,38 @@
+import { NextRequest, NextResponse } from "next/server"
+
+const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+
+export async function POST(request: NextRequest) {
+ try {
+ const sessionToken = request.headers.get("x-session-token")
+
+ const headers: Record = {
+ "Content-Type": "application/json",
+ }
+
+ if (sessionToken) {
+ headers["X-Session-Token"] = sessionToken
+ }
+
+ const response = await fetch(`${API_BASE_URL}/api/admin/retrain`, {
+ method: "POST",
+ headers,
+ })
+
+ if (!response.ok) {
+ return NextResponse.json(
+ { error: "Failed to initiate retraining" },
+ { status: response.status }
+ )
+ }
+
+ const data = await response.json()
+ return NextResponse.json(data)
+ } catch (error) {
+ console.error("Error initiating retrain:", error)
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/app/api/admin/review-queue/[id]/approve/route.ts b/src/app/api/admin/review-queue/[id]/approve/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bd3f74d0ba49ff314b5d677bfa88cbdfbd845e24
--- /dev/null
+++ b/src/app/api/admin/review-queue/[id]/approve/route.ts
@@ -0,0 +1,45 @@
+import { NextRequest, NextResponse } from "next/server"
+
+const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const { id } = await params
+ try {
+ const sessionToken = request.headers.get("x-session-token")
+
+ const headers: Record = {
+ "Content-Type": "application/json",
+ }
+
+ if (sessionToken) {
+ headers["X-Session-Token"] = sessionToken
+ }
+
+ const response = await fetch(
+ `${API_BASE_URL}/api/admin/review-queue/${id}/approve`,
+ {
+ method: "POST",
+ headers,
+ }
+ )
+
+ if (!response.ok) {
+ return NextResponse.json(
+ { error: "Failed to approve review" },
+ { status: response.status }
+ )
+ }
+
+ const data = await response.json()
+ return NextResponse.json(data)
+ } catch (error) {
+ console.error("Error approving review:", error)
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/app/api/admin/review-queue/[id]/reject/route.ts b/src/app/api/admin/review-queue/[id]/reject/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a9e6232b0048e1cf14a80f7b7682bd75d14dd1f1
--- /dev/null
+++ b/src/app/api/admin/review-queue/[id]/reject/route.ts
@@ -0,0 +1,47 @@
+import { NextRequest, NextResponse } from "next/server"
+
+const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const { id } = await params
+ try {
+ const sessionToken = request.headers.get("x-session-token")
+ const body = await request.json()
+
+ const headers: Record = {
+ "Content-Type": "application/json",
+ }
+
+ if (sessionToken) {
+ headers["X-Session-Token"] = sessionToken
+ }
+
+ const response = await fetch(
+ `${API_BASE_URL}/api/admin/review-queue/${id}/reject`,
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify(body),
+ }
+ )
+
+ if (!response.ok) {
+ return NextResponse.json(
+ { error: "Failed to reject review" },
+ { status: response.status }
+ )
+ }
+
+ const data = await response.json()
+ return NextResponse.json(data)
+ } catch (error) {
+ console.error("Error rejecting review:", error)
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/app/api/admin/review-queue/route.ts b/src/app/api/admin/review-queue/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fce71798e2e78c80b68c66e469d74a1e0442df9c
--- /dev/null
+++ b/src/app/api/admin/review-queue/route.ts
@@ -0,0 +1,37 @@
+import { NextRequest, NextResponse } from "next/server"
+
+const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+
+export async function GET(request: NextRequest) {
+ try {
+ const sessionToken = request.headers.get("x-session-token")
+
+ const headers: Record = {
+ "Content-Type": "application/json",
+ }
+
+ if (sessionToken) {
+ headers["X-Session-Token"] = sessionToken
+ }
+
+ const response = await fetch(`${API_BASE_URL}/api/admin/review-queue`, {
+ headers,
+ })
+
+ if (!response.ok) {
+ return NextResponse.json(
+ { error: "Failed to fetch review queue" },
+ { status: response.status }
+ )
+ }
+
+ const data = await response.json()
+ return NextResponse.json(data)
+ } catch (error) {
+ console.error("Error fetching review queue:", error)
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/app/api/cache/admin/analytics/route.ts b/src/app/api/cache/admin/analytics/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..dcb39a3090469bf37986da105d1bad6c30300cc8
--- /dev/null
+++ b/src/app/api/cache/admin/analytics/route.ts
@@ -0,0 +1,48 @@
+import { NextRequest, NextResponse } from "next/server"
+import { getCached, setCached, CACHE_KEYS, CACHE_TTL } from "@/lib/redis"
+
+export async function GET(request: NextRequest) {
+ try {
+ const cacheKey = CACHE_KEYS.ADMIN_ANALYTICS
+
+ // Try to get from cache first
+ const cached = await getCached(cacheKey)
+ if (cached) {
+ return NextResponse.json(cached, {
+ headers: { "X-Cache": "HIT" },
+ })
+ }
+
+ // Fetch from backend
+ const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+ const userToken = request.headers.get("X-Session-Token") || null
+ const headers: Record = {}
+ if (userToken) {
+ headers["X-Session-Token"] = userToken
+ }
+
+ const response = await fetch(`${apiBaseUrl}/api/v1/auth/analytics/dashboard`, { headers })
+
+ if (!response.ok) {
+ return NextResponse.json(
+ { error: "Failed to fetch analytics" },
+ { status: response.status }
+ )
+ }
+
+ const analytics = await response.json()
+
+ // Cache the result
+ await setCached(cacheKey, analytics, CACHE_TTL.ADMIN_ANALYTICS)
+
+ return NextResponse.json(analytics, {
+ headers: { "X-Cache": "MISS" },
+ })
+ } catch (error: any) {
+ console.error("Analytics cache error:", error)
+ return NextResponse.json(
+ { error: error.message || "Internal server error" },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/app/api/cache/admin/config/route.ts b/src/app/api/cache/admin/config/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..85100ed22bdb79422ab9fd2fec4406d596397c01
--- /dev/null
+++ b/src/app/api/cache/admin/config/route.ts
@@ -0,0 +1,63 @@
+import { NextRequest, NextResponse } from "next/server"
+import { getCached, setCached, deleteCacheKey, CACHE_KEYS, CACHE_TTL } from "@/lib/redis"
+
+export async function GET(request: NextRequest) {
+ try {
+ const cacheKey = CACHE_KEYS.ADMIN_CONFIG
+
+ // Try to get from cache first
+ const cached = await getCached(cacheKey)
+ if (cached) {
+ return NextResponse.json(cached, {
+ headers: { "X-Cache": "HIT" },
+ })
+ }
+
+ // Fetch from backend
+ const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+ const userToken = request.headers.get("X-Session-Token") || null
+ const headers: Record = {}
+ if (userToken) {
+ headers["X-Session-Token"] = userToken
+ }
+
+ const response = await fetch(`${apiBaseUrl}/api/admin/config`, { headers })
+
+ if (!response.ok) {
+ return NextResponse.json(
+ { error: "Failed to fetch config" },
+ { status: response.status }
+ )
+ }
+
+ const config = await response.json()
+
+ // Cache the result
+ await setCached(cacheKey, config, CACHE_TTL.ADMIN_CONFIG)
+
+ return NextResponse.json(config, {
+ headers: { "X-Cache": "MISS" },
+ })
+ } catch (error: any) {
+ console.error("Config cache error:", error)
+ return NextResponse.json(
+ { error: error.message || "Internal server error" },
+ { status: 500 }
+ )
+ }
+}
+
+// DELETE: Invalidate config cache
+export async function DELETE() {
+ try {
+ const cacheKey = CACHE_KEYS.ADMIN_CONFIG
+ await deleteCacheKey(cacheKey)
+ return NextResponse.json({ success: true })
+ } catch (error: any) {
+ console.error("Delete config cache error:", error)
+ return NextResponse.json(
+ { error: error.message || "Internal server error" },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/app/api/cache/admin/database-storage/route.ts b/src/app/api/cache/admin/database-storage/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..28371005fdcbb1dcb6aaa9bba84c3b0e4e01b41c
--- /dev/null
+++ b/src/app/api/cache/admin/database-storage/route.ts
@@ -0,0 +1,48 @@
+import { NextRequest, NextResponse } from "next/server"
+import { getCached, setCached, CACHE_KEYS, CACHE_TTL } from "@/lib/redis"
+
+export async function GET(request: NextRequest) {
+ try {
+ const cacheKey = CACHE_KEYS.ADMIN_DATABASE_STORAGE
+
+ // Try to get from cache first
+ const cached = await getCached(cacheKey)
+ if (cached) {
+ return NextResponse.json(cached, {
+ headers: { "X-Cache": "HIT" },
+ })
+ }
+
+ // Fetch from backend
+ const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+ const userToken = request.headers.get("X-Session-Token") || null
+ const headers: Record = {}
+ if (userToken) {
+ headers["X-Session-Token"] = userToken
+ }
+
+ const response = await fetch(`${apiBaseUrl}/api/admin/database-storage`, { headers })
+
+ if (!response.ok) {
+ return NextResponse.json(
+ { error: "Failed to fetch database storage" },
+ { status: response.status }
+ )
+ }
+
+ const storage = await response.json()
+
+ // Cache the result
+ await setCached(cacheKey, storage, CACHE_TTL.ADMIN_DATABASE_STORAGE)
+
+ return NextResponse.json(storage, {
+ headers: { "X-Cache": "MISS" },
+ })
+ } catch (error: any) {
+ console.error("Database storage cache error:", error)
+ return NextResponse.json(
+ { error: error.message || "Internal server error" },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/app/api/cache/admin/databases/route.ts b/src/app/api/cache/admin/databases/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..01ad92200aae08bed3d2f13ffad8b7800f716ee4
--- /dev/null
+++ b/src/app/api/cache/admin/databases/route.ts
@@ -0,0 +1,48 @@
+import { NextRequest, NextResponse } from "next/server"
+import { getCached, setCached, CACHE_KEYS, CACHE_TTL } from "@/lib/redis"
+
+export async function GET(request: NextRequest) {
+ try {
+ const cacheKey = CACHE_KEYS.ADMIN_DATABASES
+
+ // Try to get from cache first
+ const cached = await getCached(cacheKey)
+ if (cached) {
+ return NextResponse.json(cached, {
+ headers: { "X-Cache": "HIT" },
+ })
+ }
+
+ // Fetch from backend
+ const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+ const userToken = request.headers.get("X-Session-Token") || null
+ const headers: Record = {}
+ if (userToken) {
+ headers["X-Session-Token"] = userToken
+ }
+
+ const response = await fetch(`${apiBaseUrl}/api/admin/databases`, { headers })
+
+ if (!response.ok) {
+ return NextResponse.json(
+ { error: "Failed to fetch databases" },
+ { status: response.status }
+ )
+ }
+
+ const databases = await response.json()
+
+ // Cache the result
+ await setCached(cacheKey, databases, CACHE_TTL.ADMIN_DATABASES)
+
+ return NextResponse.json(databases, {
+ headers: { "X-Cache": "MISS" },
+ })
+ } catch (error: any) {
+ console.error("Databases cache error:", error)
+ return NextResponse.json(
+ { error: error.message || "Internal server error" },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/app/api/cache/admin/documents/route.ts b/src/app/api/cache/admin/documents/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..5da5080c659e73d7366c907e3babc2364ec9d631
--- /dev/null
+++ b/src/app/api/cache/admin/documents/route.ts
@@ -0,0 +1,51 @@
+import { NextRequest, NextResponse } from "next/server"
+import { getCached, setCached, CACHE_KEYS, CACHE_TTL, hashQueryString } from "@/lib/redis"
+
+export async function GET(request: NextRequest) {
+ try {
+ // Get query string and hash it for cache key
+ const queryString = request.nextUrl.search || ""
+ const queryHash = hashQueryString(queryString)
+ const cacheKey = CACHE_KEYS.ADMIN_DOCUMENTS(queryHash)
+
+ // Try to get from cache first
+ const cached = await getCached(cacheKey)
+ if (cached) {
+ return NextResponse.json(cached, {
+ headers: { "X-Cache": "HIT" },
+ })
+ }
+
+ // Fetch from backend
+ const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+ const userToken = request.headers.get("X-Session-Token") || null
+ const headers: Record = {}
+ if (userToken) {
+ headers["X-Session-Token"] = userToken
+ }
+
+ const response = await fetch(`${apiBaseUrl}/api/admin/documents${queryString}`, { headers })
+
+ if (!response.ok) {
+ return NextResponse.json(
+ { error: "Failed to fetch documents" },
+ { status: response.status }
+ )
+ }
+
+ const documents = await response.json()
+
+ // Cache the result
+ await setCached(cacheKey, documents, CACHE_TTL.ADMIN_DOCUMENTS)
+
+ return NextResponse.json(documents, {
+ headers: { "X-Cache": "MISS" },
+ })
+ } catch (error: any) {
+ console.error("Documents cache error:", error)
+ return NextResponse.json(
+ { error: error.message || "Internal server error" },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/app/api/cache/admin/stats/route.ts b/src/app/api/cache/admin/stats/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7ab5d859f6c6b3b7d2c7c58d93e64b1aed477ba5
--- /dev/null
+++ b/src/app/api/cache/admin/stats/route.ts
@@ -0,0 +1,48 @@
+import { NextRequest, NextResponse } from "next/server"
+import { getCached, setCached, CACHE_KEYS, CACHE_TTL } from "@/lib/redis"
+
+export async function GET(request: NextRequest) {
+ try {
+ const cacheKey = CACHE_KEYS.ADMIN_STATS
+
+ // Try to get from cache first
+ const cached = await getCached(cacheKey)
+ if (cached) {
+ return NextResponse.json(cached, {
+ headers: { "X-Cache": "HIT" },
+ })
+ }
+
+ // Fetch from backend
+ const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+ const userToken = request.headers.get("X-Session-Token") || null
+ const headers: Record = {}
+ if (userToken) {
+ headers["X-Session-Token"] = userToken
+ }
+
+ const response = await fetch(`${apiBaseUrl}/stats`, { headers })
+
+ if (!response.ok) {
+ return NextResponse.json(
+ { error: "Failed to fetch stats" },
+ { status: response.status }
+ )
+ }
+
+ const stats = await response.json()
+
+ // Cache the result
+ await setCached(cacheKey, stats, CACHE_TTL.ADMIN_STATS)
+
+ return NextResponse.json(stats, {
+ headers: { "X-Cache": "MISS" },
+ })
+ } catch (error: any) {
+ console.error("Stats cache error:", error)
+ return NextResponse.json(
+ { error: error.message || "Internal server error" },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/app/api/cache/admin/users/route.ts b/src/app/api/cache/admin/users/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..9b92f8210552c3fb0b2d32a5cba5eb62efdb6dbc
--- /dev/null
+++ b/src/app/api/cache/admin/users/route.ts
@@ -0,0 +1,56 @@
+import { NextRequest, NextResponse } from "next/server"
+import { getCached, setCached, CACHE_KEYS, CACHE_TTL } from "@/lib/redis"
+
+export async function GET(request: NextRequest) {
+ try {
+ // Get page from query params
+ const searchParams = request.nextUrl.searchParams
+ const page = parseInt(searchParams.get("page") || "1")
+ const pageSize = searchParams.get("page_size") || "20"
+
+ const cacheKey = CACHE_KEYS.ADMIN_USERS(page)
+
+ // Try to get from cache first
+ const cached = await getCached(cacheKey)
+ if (cached) {
+ return NextResponse.json(cached, {
+ headers: { "X-Cache": "HIT" },
+ })
+ }
+
+ // Fetch from backend
+ const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+ const userToken = request.headers.get("X-Session-Token") || null
+ const headers: Record = {}
+ if (userToken) {
+ headers["X-Session-Token"] = userToken
+ }
+
+ const response = await fetch(
+ `${apiBaseUrl}/api/v1/auth/admin/users?page=${page}&page_size=${pageSize}`,
+ { headers }
+ )
+
+ if (!response.ok) {
+ return NextResponse.json(
+ { error: "Failed to fetch users" },
+ { status: response.status }
+ )
+ }
+
+ const users = await response.json()
+
+ // Cache the result
+ await setCached(cacheKey, users, CACHE_TTL.ADMIN_USERS)
+
+ return NextResponse.json(users, {
+ headers: { "X-Cache": "MISS" },
+ })
+ } catch (error: any) {
+ console.error("Users cache error:", error)
+ return NextResponse.json(
+ { error: error.message || "Internal server error" },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/app/api/cache/sessions/[sessionId]/messages/route.ts b/src/app/api/cache/sessions/[sessionId]/messages/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..703caf23ab028aa956616bd8c022c0d7bea1ec4e
--- /dev/null
+++ b/src/app/api/cache/sessions/[sessionId]/messages/route.ts
@@ -0,0 +1,79 @@
+import { NextRequest, NextResponse } from "next/server"
+import { getCached, setCached, deleteCacheKey, CACHE_KEYS, CACHE_TTL } from "@/lib/redis"
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ sessionId: string }> }
+) {
+ try {
+ const { sessionId } = await params
+ const cacheKey = CACHE_KEYS.SESSION_MESSAGES(sessionId)
+
+ // Try to get from cache first
+ const cached = await getCached(cacheKey)
+ if (cached) {
+ return NextResponse.json(cached, {
+ headers: { "X-Cache": "HIT" },
+ })
+ }
+
+ // Fetch from backend
+ const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+ const userToken = request.headers.get("X-Session-Token") || null
+ const headers: Record = {
+ "Content-Type": "application/json",
+ }
+ if (userToken) {
+ headers["X-Session-Token"] = userToken
+ }
+
+ const response = await fetch(
+ `${apiBaseUrl}/api/v1/chat/sessions/${sessionId}/messages`,
+ { headers }
+ )
+
+ if (!response.ok) {
+ return NextResponse.json(
+ { error: "Failed to fetch messages" },
+ { status: response.status }
+ )
+ }
+
+ const messages = await response.json()
+
+ // Cache the result
+ await setCached(cacheKey, messages, CACHE_TTL.SESSION_MESSAGES)
+
+ return NextResponse.json(messages, {
+ headers: { "X-Cache": "MISS" },
+ })
+ } catch (error: unknown) {
+ console.error("Messages cache error:", error)
+ const errorMessage = typeof error === "object" && error !== null && "message" in error ? (error as { message?: string }).message : "Internal server error";
+ return NextResponse.json(
+ { error: errorMessage || "Internal server error" },
+ { status: 500 }
+ )
+ }
+}
+
+// DELETE: Invalidate messages cache for a specific session
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ sessionId: string }> }
+) {
+ try {
+ const { sessionId } = await params
+ const cacheKey = CACHE_KEYS.SESSION_MESSAGES(sessionId)
+ await deleteCacheKey(cacheKey)
+
+ return NextResponse.json({ success: true })
+ } catch (error: unknown) {
+ console.error("Delete messages cache error:", error)
+ const errorMessage = typeof error === "object" && error !== null && "message" in error ? (error as { message?: string }).message : "Internal server error";
+ return NextResponse.json(
+ { error: errorMessage || "Internal server error" },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/app/api/cache/sessions/route.ts b/src/app/api/cache/sessions/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c4e1152015cee57cc4709b5a58d3e058e45716f5
--- /dev/null
+++ b/src/app/api/cache/sessions/route.ts
@@ -0,0 +1,73 @@
+import { NextRequest, NextResponse } from "next/server"
+import { getCached, setCached, CACHE_KEYS, CACHE_TTL, getUserTokenHash } from "@/lib/redis"
+
+export async function GET(request: NextRequest) {
+ try {
+ const userToken = request.headers.get("X-Session-Token") || null
+ const tokenHash = getUserTokenHash(userToken)
+ const cacheKey = CACHE_KEYS.SESSIONS(tokenHash)
+
+ // Try to get from cache first
+ const cached = await getCached(cacheKey)
+ if (cached) {
+ return NextResponse.json(cached, {
+ headers: { "X-Cache": "HIT" },
+ })
+ }
+
+ // Fetch from backend
+ const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+ const headers: Record = {
+ "Content-Type": "application/json",
+ }
+ if (userToken) {
+ headers["X-Session-Token"] = userToken
+ }
+
+ const response = await fetch(`${apiBaseUrl}/api/v1/chat/sessions`, {
+ headers,
+ })
+
+ if (!response.ok) {
+ return NextResponse.json(
+ { error: "Failed to fetch sessions" },
+ { status: response.status }
+ )
+ }
+
+ const sessions = await response.json()
+
+ // Cache the result
+ await setCached(cacheKey, sessions, CACHE_TTL.SESSIONS)
+
+ return NextResponse.json(sessions, {
+ headers: { "X-Cache": "MISS" },
+ })
+ } catch (error: any) {
+ console.error("Sessions cache error:", error)
+ return NextResponse.json(
+ { error: error.message || "Internal server error" },
+ { status: 500 }
+ )
+ }
+}
+
+// DELETE: Invalidate sessions cache
+export async function DELETE(request: NextRequest) {
+ try {
+ const userToken = request.headers.get("X-Session-Token") || null
+ const tokenHash = getUserTokenHash(userToken)
+ const cacheKey = CACHE_KEYS.SESSIONS(tokenHash)
+
+ const { deleteCacheKey } = await import("@/lib/redis")
+ await deleteCacheKey(cacheKey)
+
+ return NextResponse.json({ success: true })
+ } catch (error: any) {
+ console.error("Delete sessions cache error:", error)
+ return NextResponse.json(
+ { error: error.message || "Internal server error" },
+ { status: 500 }
+ )
+ }
+}
diff --git a/src/app/api/livekit-token/route.ts b/src/app/api/livekit-token/route.ts
new file mode 100644
index 0000000000000000000000000000000000000000..66b1ed3e48e0e72f1073487302dd58993b6dd016
--- /dev/null
+++ b/src/app/api/livekit-token/route.ts
@@ -0,0 +1,133 @@
+import { NextRequest, NextResponse } from "next/server"
+import { AccessToken } from "livekit-server-sdk"
+
+export async function POST(request: NextRequest) {
+ try {
+ const { roomName, participantName } = await request.json()
+
+ if (!roomName) {
+ return NextResponse.json(
+ { error: "roomName is required" },
+ { status: 400 }
+ )
+ }
+
+ // Use server-side environment variables only (not NEXT_PUBLIC_* which are exposed to client)
+ // These must match the credentials used by your LiveKit server
+ const apiKey = process.env.LIVEKIT_API_KEY
+ const apiSecret = process.env.LIVEKIT_API_SECRET
+
+ if (!apiKey) {
+ return NextResponse.json(
+ {
+ error: "LIVEKIT_API_KEY not configured",
+ hint: "Set LIVEKIT_API_KEY environment variable (server-side, not NEXT_PUBLIC_*). This must match the API key configured in your LiveKit server. Get it from LiveKit Cloud dashboard (Settings > Keys) or your self-hosted LiveKit config."
+ },
+ { status: 500 }
+ )
+ }
+
+ if (!apiSecret) {
+ return NextResponse.json(
+ {
+ error: "LIVEKIT_API_SECRET not configured",
+ hint: "Set LIVEKIT_API_SECRET environment variable (server-side). This must match the API secret configured in your LiveKit server.",
+ instructions: [
+ "For LiveKit Cloud:",
+ " 1. Go to Settings > Keys in your LiveKit Cloud dashboard",
+ " 2. Click on your API key row or the menu (three dots) to reveal the secret",
+ " 3. If the secret is not visible, you may need to create a NEW API key",
+ " 4. When creating a new key, copy BOTH the API key AND the secret immediately",
+ " 5. Note: Secrets are only shown once when created - save it securely!",
+ "",
+ "For self-hosted LiveKit:",
+ " - Check your LiveKit server configuration file (usually livekit.yaml)",
+ " - Or use the default 'secret' for development mode"
+ ]
+ },
+ { status: 500 }
+ )
+ }
+
+ // Validate API secret format - it should NOT be a JWT token
+ const trimmedSecret = apiSecret.trim()
+ if (trimmedSecret.startsWith('eyJ')) {
+ console.error(`[LiveKit Token] ❌ ERROR: API secret appears to be a JWT token, not a secret key!`)
+ console.error(`[LiveKit Token] The secret starts with 'eyJ' which indicates it's a JWT token.`)
+ console.error(`[LiveKit Token] LiveKit API secret should be a random string, not a token.`)
+ return NextResponse.json(
+ {
+ error: "Invalid API secret format",
+ hint: "Your LIVEKIT_API_SECRET appears to be a JWT token (starts with 'eyJ'), but it should be the actual secret key. In LiveKit Cloud: Go to Settings > Keys and copy the 'API Secret' (not a token). It should be a long random string, not a JWT token.",
+ details: "The API secret is used to SIGN tokens, not a token itself. Make sure you're using the secret key from your LiveKit dashboard, not a generated token."
+ },
+ { status: 500 }
+ )
+ }
+
+ // Log API key prefix for debugging (without exposing full key or secret)
+ console.log(`[LiveKit Token] Using API key: ${apiKey.substring(0, 8)}...`)
+ console.log(`[LiveKit Token] API secret length: ${trimmedSecret.length} characters`)
+ console.log(`[LiveKit Token] API secret starts with: ${trimmedSecret.substring(0, 4)}...`)
+
+ // Warn if secret looks suspicious
+ if (trimmedSecret.length < 20) {
+ console.warn(`[LiveKit Token] ⚠️ WARNING: API secret is very short (${trimmedSecret.length} chars). LiveKit secrets are usually much longer.`)
+ }
+
+ const at = new AccessToken(apiKey, trimmedSecret, {
+ identity: participantName || "user",
+ })
+
+ at.addGrant({
+ room: roomName,
+ roomJoin: true,
+ canPublish: true,
+ canSubscribe: true,
+ })
+
+ const token = await at.toJwt()
+
+ // Decode token to verify it was created correctly (for debugging)
+ const tokenParts = token.split('.')
+ if (tokenParts.length === 3) {
+ try {
+ const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64').toString())
+ console.log(`[LiveKit Token] Generated token details:`)
+ console.log(` - Room: ${payload.video?.room}`)
+ console.log(` - Identity: ${payload.sub}`)
+ console.log(` - Issuer (API Key): ${payload.iss}`)
+ console.log(` - Expires: ${new Date(payload.exp * 1000).toISOString()}`)
+ console.log(`[LiveKit Token] ⚠️ If token validation fails, verify:`)
+ console.log(` 1. API Key in token (${payload.iss}) matches your LiveKit server's API key`)
+ console.log(` 2. API Secret used to sign token matches your LiveKit server's API secret`)
+ console.log(` 3. Both are set in your Next.js environment variables (not NEXT_PUBLIC_*)`)
+ } catch (e) {
+ // Ignore decode errors
+ console.error(`[LiveKit Token] Failed to decode token payload:`, e)
+ }
+ }
+
+ return NextResponse.json({ token })
+ } catch (error) {
+ console.error("[LiveKit Token] Error generating token:", error)
+ const errorMessage = error instanceof Error ? error.message : "Failed to generate token"
+
+ // Provide detailed troubleshooting information
+ const troubleshooting = {
+ error: errorMessage,
+ hint: "Token signature verification failed. This means the API secret doesn't match your LiveKit server.",
+ steps: [
+ "1. Verify LIVEKIT_API_KEY matches your LiveKit server's API key",
+ "2. Verify LIVEKIT_API_SECRET matches your LiveKit server's API secret (exactly, including any spaces)",
+ "3. For LiveKit Cloud: Get credentials from Settings > Keys in your dashboard",
+ "4. For self-hosted: Check your LiveKit server configuration file",
+ "5. Ensure environment variables are set in .env.local (not .env) for Next.js",
+ "6. Restart your Next.js dev server after changing environment variables"
+ ]
+ }
+
+ return NextResponse.json(troubleshooting, { status: 500 })
+ }
+}
+
diff --git a/src/app/auth/forgot-password/page.tsx b/src/app/auth/forgot-password/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6418e53f6a3e8a9a269f9480ef548507ca116a95
--- /dev/null
+++ b/src/app/auth/forgot-password/page.tsx
@@ -0,0 +1,342 @@
+"use client"
+
+import { useState } from "react"
+import { useRouter } from "next/navigation"
+import Link from "next/link"
+import { apiClient } from "@/lib/api-client"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { OTPInput } from "@/components/ui/otp-input"
+import { toast } from "sonner"
+import { Loader2 } from "lucide-react"
+
+export default function ForgotPasswordPage() {
+ const [step, setStep] = useState<"email" | "otp" | "reset">("email")
+ const [loading, setLoading] = useState(false)
+ const [email, setEmail] = useState("")
+ const [phoneNumber, setPhoneNumber] = useState("")
+ const [maskedPhoneNumber, setMaskedPhoneNumber] = useState(null)
+ const [otp, setOtp] = useState("")
+ const [resetToken, setResetToken] = useState("")
+ const [newPassword, setNewPassword] = useState("")
+ const router = useRouter()
+
+ const handleEmailSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setLoading(true)
+
+ try {
+ // Request password reset (this will return masked phone number)
+ const response = await apiClient.post<{ message: string; phone_number?: string }>(
+ "/api/v1/auth/password/reset-request",
+ {
+ email: email,
+ }
+ )
+
+ if (response.phone_number) {
+ // Store masked phone number for display
+ setMaskedPhoneNumber(response.phone_number)
+ // Show masked phone number to user - they still need to enter full number for OTP verification
+ toast.success(`${response.message} (ending in ${response.phone_number.slice(-4)})`)
+ setStep("otp")
+ } else {
+ // No phone number found, ask user to enter it
+ setMaskedPhoneNumber(null)
+ toast.success("Please enter your phone number to receive OTP")
+ setStep("otp")
+ }
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : "Failed to request password reset"
+ toast.error(errorMessage)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleOTPVerify = async () => {
+ if (otp.length !== 6) {
+ toast.error("Please enter a valid 6-digit OTP")
+ return
+ }
+
+ setLoading(true)
+
+ try {
+ // Verify OTP and get password reset token
+ const response = await apiClient.post<{ status: string; message: string; reset_token?: string }>(
+ "/api/v1/auth/phone/verify-otp",
+ {
+ phone_number: phoneNumber,
+ otp: otp,
+ purpose: "password_reset",
+ }
+ )
+
+ if (response.reset_token) {
+ setResetToken(response.reset_token)
+ setStep("reset")
+ toast.success("OTP verified. Please set your new password.")
+ } else {
+ // Fallback: request reset token again if not returned
+ await apiClient.post("/api/v1/auth/password/reset-request", {
+ email: email,
+ })
+ // Note: In production, the token should always come from OTP verification
+ setResetToken("verified")
+ setStep("reset")
+ toast.success("OTP verified. Please set your new password.")
+ }
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : "OTP verification failed"
+ toast.error(errorMessage)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handlePasswordReset = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setLoading(true)
+
+ try {
+ // Reset password with token and OTP verification
+ await apiClient.post("/api/v1/auth/password/reset", {
+ token: resetToken,
+ new_password: newPassword,
+ })
+
+ toast.success("Password reset successfully! Please sign in.")
+ router.push("/auth/signin")
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : "Password reset failed"
+ toast.error(errorMessage)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleResendOTP = async () => {
+ setLoading(true)
+ try {
+ await apiClient.post("/api/v1/auth/phone/resend-otp", {
+ phone_number: phoneNumber,
+ purpose: "password_reset",
+ })
+ toast.success("OTP resent successfully")
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : "Failed to resend OTP"
+ toast.error(errorMessage)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (step === "otp") {
+ return (
+
+
+
+ Verify OTP
+
+ {maskedPhoneNumber
+ ? `Enter the 6-digit code sent to ${maskedPhoneNumber}`
+ : phoneNumber
+ ? `Enter the 6-digit code sent to ${phoneNumber}`
+ : "Enter the 6-digit code sent to your phone number"}
+
+
+
+ {!maskedPhoneNumber && (
+
+
+ Phone Number
+
+
setPhoneNumber(e.target.value)}
+ required
+ className="mt-1"
+ />
+
+ Format: +254712345678, +2541XXXXXXX, 0712345678, or 01XXXXXXX
+
+
+ )}
+ {maskedPhoneNumber && !phoneNumber && (
+
+
+ Confirm Phone Number
+
+
setPhoneNumber(e.target.value)}
+ required
+ className="mt-1"
+ />
+
+ Enter your full phone number to verify
+
+
+ )}
+
+
+ {loading ? (
+ <>
+
+ Verifying...
+ >
+ ) : (
+ "Verify OTP"
+ )}
+
+
+
+ Resend OTP
+
+
+
+ setStep("email")}
+ className="text-muted-foreground hover:underline"
+ >
+ Back
+
+
+
+
+
+ )
+ }
+
+ if (step === "reset") {
+ return (
+
+
+
+ Reset Password
+ Enter your new password
+
+
+
+
+
+ Back to sign in
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ Forgot Password
+
+ Enter your email and phone number to reset your password
+
+
+
+
+
+
+ Back to sign in
+
+
+
+
+
+ )
+}
+
diff --git a/src/app/auth/reset-password/page.tsx b/src/app/auth/reset-password/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..87d9b6bde855cafbfe43a7f29ccf806a53568130
--- /dev/null
+++ b/src/app/auth/reset-password/page.tsx
@@ -0,0 +1,221 @@
+"use client"
+
+import { useState, useEffect, Suspense } from "react"
+import { useRouter, useSearchParams } from "next/navigation"
+import Link from "next/link"
+import { apiClient } from "@/lib/api-client"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { OTPInput } from "@/components/ui/otp-input"
+import { toast } from "sonner"
+import { Loader2 } from "lucide-react"
+
+function ResetPasswordForm() {
+ const [step, setStep] = useState<"otp" | "reset">("otp")
+ const [loading, setLoading] = useState(false)
+ const [token, setToken] = useState("")
+ const [phoneNumber, setPhoneNumber] = useState("")
+ const [otp, setOtp] = useState("")
+ const [newPassword, setNewPassword] = useState("")
+ const router = useRouter()
+ const searchParams = useSearchParams()
+
+ useEffect(() => {
+ const tokenParam = searchParams.get("token")
+ if (tokenParam) {
+ setToken(tokenParam)
+ } else {
+ toast.error("Invalid reset link")
+ router.push("/auth/forgot-password")
+ }
+ }, [searchParams, router])
+
+ const handleOTPVerify = async () => {
+ if (otp.length !== 6) {
+ toast.error("Please enter a valid 6-digit OTP")
+ return
+ }
+
+ setLoading(true)
+
+ try {
+ // Verify OTP
+ await apiClient.post("/api/v1/auth/phone/verify-otp", {
+ phone_number: phoneNumber,
+ otp: otp,
+ purpose: "password_reset",
+ })
+
+ setStep("reset")
+ toast.success("OTP verified. Please set your new password.")
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : "OTP verification failed"
+ toast.error(errorMessage)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handlePasswordReset = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setLoading(true)
+
+ try {
+ // Reset password with token
+ await apiClient.post("/api/v1/auth/password/reset", {
+ token: token,
+ new_password: newPassword,
+ })
+
+ toast.success("Password reset successfully! Please sign in.")
+ router.push("/auth/signin")
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : "Password reset failed"
+ toast.error(errorMessage)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleResendOTP = async () => {
+ setLoading(true)
+ try {
+ await apiClient.post("/api/v1/auth/phone/resend-otp", {
+ phone_number: phoneNumber,
+ purpose: "password_reset",
+ })
+ toast.success("OTP resent successfully")
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : "Failed to resend OTP"
+ toast.error(errorMessage)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (step === "otp") {
+ return (
+
+
+
+ Verify OTP
+
+ Enter your phone number and the OTP code sent to you
+
+
+
+
+
+ Phone Number
+
+
setPhoneNumber(e.target.value)}
+ required
+ />
+
+ Format: +254712345678, +2541XXXXXXX, 0712345678, or 01XXXXXXX
+
+
+
+
+ {loading ? (
+ <>
+
+ Verifying...
+ >
+ ) : (
+ "Verify OTP"
+ )}
+
+
+
+ Resend OTP
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ Reset Password
+ Enter your new password
+
+
+
+
+
+ Back to sign in
+
+
+
+
+
+ )
+}
+
+export default function ResetPasswordPage() {
+ return (
+
+
+
+
+
+
+
+ }>
+
+
+ )
+}
diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8517407baf8b4dc60e5c2908edb6a4d108633305
--- /dev/null
+++ b/src/app/auth/signin/page.tsx
@@ -0,0 +1,104 @@
+"use client"
+
+import { useState } from "react"
+import { useRouter } from "next/navigation"
+import Link from "next/link"
+import { useAuth } from "@/lib/auth-context"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { toast } from "sonner"
+import { Loader2 } from "lucide-react"
+
+export default function SignInPage() {
+ const [email, setEmail] = useState("")
+ const [password, setPassword] = useState("")
+ const [loading, setLoading] = useState(false)
+ const router = useRouter()
+ const { login } = useAuth()
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setLoading(true)
+
+ try {
+ await login(email, password)
+ toast.success("Signed in successfully")
+ // Navigation handled by auth context
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : "Sign in failed"
+ toast.error(errorMessage)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+
+ Sign In
+
+ Enter your credentials to access your account
+
+
+
+
+
+ Don't have an account?
+
+ Sign up
+
+
+
+
+
+ )
+}
+
diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..eab4494ef9e79ed69435752341af3c3f938da712
--- /dev/null
+++ b/src/app/auth/signup/page.tsx
@@ -0,0 +1,255 @@
+"use client"
+
+import { useState } from "react"
+import { useRouter } from "next/navigation"
+import Link from "next/link"
+import { useAuth } from "@/lib/auth-context"
+import { apiClient } from "@/lib/api-client"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { OTPInput } from "@/components/ui/otp-input"
+import { toast } from "sonner"
+import { Loader2 } from "lucide-react"
+
+export default function SignUpPage() {
+ const [step, setStep] = useState<"form" | "otp">("form")
+ const [loading, setLoading] = useState(false)
+ const [formData, setFormData] = useState({
+ email: "",
+ password: "",
+ name: "",
+ phone_number: "",
+ })
+ const [otp, setOtp] = useState("")
+ const [phoneNumber, setPhoneNumber] = useState("")
+ const router = useRouter()
+ const { register } = useAuth()
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setLoading(true)
+
+ try {
+ // Register user
+ await register(
+ formData.email,
+ formData.password,
+ formData.name,
+ formData.phone_number
+ )
+
+ // Send OTP
+ await apiClient.post("/api/v1/auth/phone/send-otp", {
+ phone_number: formData.phone_number,
+ purpose: "verification",
+ })
+
+ setPhoneNumber(formData.phone_number)
+ setStep("otp")
+ toast.success("OTP sent to your phone number")
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : "Registration failed"
+ toast.error(errorMessage)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleOTPVerify = async () => {
+ if (otp.length !== 6) {
+ toast.error("Please enter a valid 6-digit OTP")
+ return
+ }
+
+ setLoading(true)
+
+ try {
+ // Verify OTP
+ await apiClient.post("/api/v1/auth/phone/verify-otp", {
+ phone_number: phoneNumber,
+ otp: otp,
+ purpose: "verification",
+ })
+
+ toast.success("Phone verified successfully! Please sign in.")
+ router.push("/auth/signin")
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : "OTP verification failed"
+ toast.error(errorMessage)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleResendOTP = async () => {
+ setLoading(true)
+ try {
+ await apiClient.post("/api/v1/auth/phone/resend-otp", {
+ phone_number: phoneNumber,
+ purpose: "verification",
+ })
+ toast.success("OTP resent successfully")
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : "Failed to resend OTP"
+ toast.error(errorMessage)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (step === "otp") {
+ return (
+
+
+
+ Verify Phone Number
+
+ Enter the 6-digit code sent to {phoneNumber}
+
+
+
+
+
+ {loading ? (
+ <>
+
+ Verifying...
+ >
+ ) : (
+ "Verify OTP"
+ )}
+
+
+
+ Resend OTP
+
+
+
+ setStep("form")}
+ className="text-muted-foreground hover:underline"
+ >
+ Back to registration
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ Create Account
+
+ Sign up to get started with AmaniQuery
+
+
+
+
+
+ Already have an account?
+
+ Sign in
+
+
+
+
+
+ )
+}
+
diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c1bc62ed47b51be9d272f7fc91c7fc5032071775
--- /dev/null
+++ b/src/app/blog/[slug]/page.tsx
@@ -0,0 +1,243 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { useParams, useRouter } from "next/navigation"
+import Link from "next/link"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent } from "@/components/ui/card"
+import { Calendar, User, ArrowLeft, Share2 } from "lucide-react"
+import Image from "next/image"
+
+const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+
+interface BlogPost {
+ id: string
+ title: string
+ slug: string
+ markdown_content?: string
+ html_content?: string
+ excerpt?: string
+ post_type: string
+ featured_image_url?: string
+ author: {
+ id: string
+ name?: string
+ email: string
+ profile_image_url?: string
+ }
+ categories: Array<{ id: string; name: string; slug: string }>
+ tags: Array<{ id: string; name: string; slug: string }>
+ published_at?: string
+ created_at: string
+ updated_at: string
+}
+
+export default function BlogPostPage() {
+ const params = useParams()
+ const router = useRouter()
+ const slug = params.slug as string
+ const [post, setPost] = useState(null)
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ const fetchPost = async () => {
+ setLoading(true)
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/posts/${slug}`)
+ if (response.ok) {
+ const data: BlogPost = await response.json()
+ setPost(data)
+ } else if (response.status === 404) {
+ router.push("/blog")
+ }
+ } catch (error) {
+ console.error("Failed to fetch post:", error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (slug) {
+ fetchPost()
+ }
+ }, [slug, router])
+
+ const formatDate = (dateString: string) => {
+ const date = new Date(dateString)
+ return date.toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ })
+ }
+
+ const getPostTypeColor = (type: string) => {
+ switch (type) {
+ case "news":
+ return "bg-blue-500/10 text-blue-500"
+ case "announcement":
+ return "bg-yellow-500/10 text-yellow-500"
+ case "update":
+ return "bg-green-500/10 text-green-500"
+ default:
+ return "bg-gray-500/10 text-gray-500"
+ }
+ }
+
+ const handleShare = async () => {
+ if (navigator.share && post) {
+ try {
+ await navigator.share({
+ title: post.title,
+ text: post.excerpt || "",
+ url: window.location.href,
+ })
+ } catch (error) {
+ // User cancelled or error occurred
+ console.log("Share cancelled or failed")
+ }
+ } else {
+ // Fallback: copy to clipboard
+ navigator.clipboard.writeText(window.location.href)
+ alert("Link copied to clipboard!")
+ }
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (!post) {
+ return (
+
+
+
+ Post not found
+
+ Back to Blog
+
+
+
+
+ )
+ }
+
+ return (
+
+
+ {/* Back Button */}
+
+
+
+ Back to Blog
+
+
+
+ {/* Header */}
+
+
+
+ {post.post_type}
+
+ {post.published_at && (
+
+
+ {formatDate(post.published_at)}
+
+ )}
+
+
{post.title}
+ {post.excerpt && (
+
{post.excerpt}
+ )}
+
+
+
+
+ {post.author.name || post.author.email}
+
+
+
+
+ Share
+
+
+
+
+ {/* Featured Image */}
+ {post.featured_image_url && (
+
+
+
+ )}
+
+ {/* Content */}
+
+ {post.html_content ? (
+
+ ) : post.markdown_content ? (
+ {post.markdown_content}
+ ) : (
+ No content available.
+ )}
+
+
+ {/* Categories and Tags */}
+ {(post.categories.length > 0 || post.tags.length > 0) && (
+
+
+ {post.categories.length > 0 && (
+
+
Categories
+
+ {post.categories.map((category) => (
+
+ {category.name}
+
+ ))}
+
+
+ )}
+ {post.tags.length > 0 && (
+
+
Tags
+
+ {post.tags.map((tag) => (
+
+ {tag.name}
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+ {/* Navigation */}
+
+
+
+
+ All Posts
+
+
+
+
Back to Home
+
+
+
+
+ )
+}
+
diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b469cf700ab6b24cc0b32f5a9a227b97f1bedb1f
--- /dev/null
+++ b/src/app/blog/page.tsx
@@ -0,0 +1,395 @@
+"use client"
+
+import { useState, useEffect, Suspense } from "react"
+import { useSearchParams, useRouter } from "next/navigation"
+import { BlogPostCard } from "@/components/blog-post-card"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent } from "@/components/ui/card"
+import {
+ Search,
+ Filter,
+ ChevronLeft,
+ ChevronRight,
+ Newspaper,
+ Megaphone,
+ RefreshCw,
+} from "lucide-react"
+import Link from "next/link"
+
+const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+
+interface BlogPost {
+ id: string
+ title: string
+ slug: string
+ excerpt?: string
+ post_type: string
+ featured_image_url?: string
+ author: {
+ id: string
+ name?: string
+ email: string
+ profile_image_url?: string
+ }
+ categories: Array<{ id: string; name: string; slug: string }>
+ tags: Array<{ id: string; name: string; slug: string }>
+ published_at?: string
+ created_at: string
+}
+
+interface BlogResponse {
+ posts: BlogPost[]
+ total: number
+ page: number
+ page_size: number
+ total_pages: number
+}
+
+interface Category {
+ id: string
+ name: string
+ slug: string
+}
+
+interface Tag {
+ id: string
+ name: string
+ slug: string
+}
+
+function BlogList() {
+ const searchParams = useSearchParams()
+ const router = useRouter()
+ const [posts, setPosts] = useState([])
+ const [categories, setCategories] = useState([])
+ const [tags, setTags] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [searchQuery, setSearchQuery] = useState(searchParams.get("search") || "")
+ const [selectedPostType, setSelectedPostType] = useState(
+ searchParams.get("post_type") || null
+ )
+ const [selectedCategory, setSelectedCategory] = useState(
+ searchParams.get("category") || null
+ )
+ const [selectedTag, setSelectedTag] = useState(
+ searchParams.get("tag") || null
+ )
+ const [currentPage, setCurrentPage] = useState(
+ parseInt(searchParams.get("page") || "1")
+ )
+ const [pagination, setPagination] = useState({
+ total: 0,
+ page: 1,
+ page_size: 10,
+ total_pages: 1,
+ })
+
+ const fetchPosts = async () => {
+ setLoading(true)
+ try {
+ const params = new URLSearchParams()
+ params.append("page", currentPage.toString())
+ params.append("page_size", "10")
+ if (selectedPostType) params.append("post_type", selectedPostType)
+ if (selectedCategory) params.append("category_slug", selectedCategory)
+ if (selectedTag) params.append("tag_slug", selectedTag)
+ if (searchQuery) params.append("search", searchQuery)
+
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/posts?${params}`)
+ if (response.ok) {
+ const data: BlogResponse = await response.json()
+ setPosts(data.posts)
+ setPagination({
+ total: data.total,
+ page: data.page,
+ page_size: data.page_size,
+ total_pages: data.total_pages,
+ })
+ }
+ } catch (error) {
+ console.error("Failed to fetch posts:", error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const fetchCategories = async () => {
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/categories`)
+ if (response.ok) {
+ const data: Category[] = await response.json()
+ setCategories(data)
+ }
+ } catch (error) {
+ console.error("Failed to fetch categories:", error)
+ }
+ }
+
+ const fetchTags = async () => {
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/v1/blog/tags`)
+ if (response.ok) {
+ const data: Tag[] = await response.json()
+ setTags(data)
+ }
+ } catch (error) {
+ console.error("Failed to fetch tags:", error)
+ }
+ }
+
+ useEffect(() => {
+ fetchCategories()
+ fetchTags()
+ }, [])
+
+ useEffect(() => {
+ fetchPosts()
+ }, [currentPage, selectedPostType, selectedCategory, selectedTag, searchQuery])
+
+ useEffect(() => {
+ // Update URL with current filters
+ const params = new URLSearchParams()
+ if (selectedPostType) params.append("post_type", selectedPostType)
+ if (selectedCategory) params.append("category", selectedCategory)
+ if (selectedTag) params.append("tag", selectedTag)
+ if (searchQuery) params.append("search", searchQuery)
+ if (currentPage > 1) params.append("page", currentPage.toString())
+
+ router.push(`/blog?${params.toString()}`, { scroll: false })
+ }, [selectedPostType, selectedCategory, selectedTag, searchQuery, currentPage, router])
+
+ const handleSearch = (e: React.FormEvent) => {
+ e.preventDefault()
+ setCurrentPage(1)
+ fetchPosts()
+ }
+
+ const clearFilters = () => {
+ setSelectedPostType(null)
+ setSelectedCategory(null)
+ setSelectedTag(null)
+ setSearchQuery("")
+ setCurrentPage(1)
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
Blog
+
+ News, announcements, and updates from AmaniQuery
+
+
+
+
Back to Home
+
+
+
+ {/* Search */}
+
+
+
+
+ {/* Sidebar Filters */}
+
+
+
+
+
+
+ Filters
+
+ {(selectedPostType || selectedCategory || selectedTag) && (
+
+ Clear
+
+ )}
+
+
+ {/* Post Type Filter */}
+
+
Post Type
+
+ {["news", "announcement", "update"].map((type) => (
+
{
+ setSelectedPostType(
+ selectedPostType === type ? null : type
+ )
+ setCurrentPage(1)
+ }}
+ className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
+ selectedPostType === type
+ ? "bg-primary text-primary-foreground"
+ : "hover:bg-accent"
+ }`}
+ >
+
+ {type === "news" && }
+ {type === "announcement" && (
+
+ )}
+ {type === "update" && (
+
+ )}
+ {type.charAt(0).toUpperCase() + type.slice(1)}
+
+
+ ))}
+
+
+
+ {/* Categories Filter */}
+ {categories.length > 0 && (
+
+
Categories
+
+ {categories.map((category) => (
+ {
+ setSelectedCategory(
+ selectedCategory === category.slug ? null : category.slug
+ )
+ setCurrentPage(1)
+ }}
+ className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
+ selectedCategory === category.slug
+ ? "bg-primary text-primary-foreground"
+ : "hover:bg-accent"
+ }`}
+ >
+ {category.name}
+
+ ))}
+
+
+ )}
+
+ {/* Tags Filter */}
+ {tags.length > 0 && (
+
+
Tags
+
+ {tags.map((tag) => (
+ {
+ setSelectedTag(
+ selectedTag === tag.slug ? null : tag.slug
+ )
+ setCurrentPage(1)
+ }}
+ >
+ {tag.name}
+
+ ))}
+
+
+ )}
+
+
+
+
+ {/* Main Content */}
+
+ {loading ? (
+
+ ) : posts.length === 0 ? (
+
+
+
+ No posts found. Try adjusting your filters.
+
+
+
+ ) : (
+ <>
+
+ {posts.map((post) => (
+
+ ))}
+
+
+ {/* Pagination */}
+ {pagination.total_pages > 1 && (
+
+ setCurrentPage((p) => Math.max(1, p - 1))}
+ disabled={currentPage === 1}
+ >
+
+ Previous
+
+
+ Page {pagination.page} of {pagination.total_pages}
+
+
+ setCurrentPage((p) =>
+ Math.min(pagination.total_pages, p + 1)
+ )
+ }
+ disabled={currentPage === pagination.total_pages}
+ >
+ Next
+
+
+
+ )}
+ >
+ )}
+
+
+
+
+ )
+}
+
+export default function BlogPage() {
+ return (
+
+
+
+ }>
+
+
+ )
+}
diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7d76863de12ead63beaf68d9ecc9332ca2922f09
--- /dev/null
+++ b/src/app/chat/page.tsx
@@ -0,0 +1,145 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import { useRouter } from "next/navigation"
+import { AmaniChat } from "@/components/chat/AmaniChat"
+import { AmaniSidebar } from "@/components/AmaniSidebar"
+import { ThemeToggle } from "@/components/theme-toggle"
+import { useAuth } from "@/lib/auth-context"
+import { cn } from "@/lib/utils"
+import { Menu } from "lucide-react"
+import type { ChatSession } from "@/components/chat/types"
+
+export default function ChatPage() {
+ const { isAuthenticated, loading } = useAuth()
+ const router = useRouter()
+ const [chatHistory, setChatHistory] = useState([])
+ const [currentSessionId, setCurrentSessionId] = useState(null)
+ const [isSidebarOpen, setIsSidebarOpen] = useState(true)
+
+
+ // Define loadChatHistory before useEffect to avoid TDZ error
+ const loadChatHistory = async () => {
+ try {
+ const token = localStorage.getItem("session_token")
+ const headers = { "X-Session-Token": token || "" }
+ const response = await fetch("/api/cache/sessions", { headers })
+ if (response.ok) {
+ const sessions = await response.json()
+ setChatHistory(sessions)
+ }
+ } catch (error) {
+ console.error("Failed to load chat history:", error)
+ }
+ }
+
+ // Load chat history on mount
+ useEffect(() => {
+ if (isAuthenticated) {
+ (async () => {
+ await loadChatHistory();
+ })();
+ }
+ }, [isAuthenticated])
+
+ useEffect(() => {
+ if (!loading && !isAuthenticated) {
+ router.push("/auth/signin?redirect=/chat")
+ }
+ }, [isAuthenticated, loading, router])
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (!isAuthenticated) {
+ return null
+ }
+
+ return (
+
+ {/* New AmaniSidebar with integrated chat history */}
+
setCurrentSessionId(null)}
+ onDeleteSession={async (sessionId) => {
+ // Handle session deletion
+ setChatHistory(prev => prev.filter(s => s.id !== sessionId))
+ if (currentSessionId === sessionId) {
+ setCurrentSessionId(null)
+ }
+ // Call API to delete session
+ try {
+ const token = localStorage.getItem("session_token")
+ const headers = { "X-Session-Token": token || "" }
+ await fetch(`/api/v1/chat/sessions/${sessionId}`, {
+ method: "DELETE",
+ headers
+ })
+ } catch (error) {
+ console.error("Failed to delete session:", error)
+ }
+ }}
+ onRenameSession={async (sessionId, newTitle) => {
+ // Handle session renaming
+ setChatHistory(prev => prev.map(s =>
+ s.id === sessionId ? { ...s, title: newTitle } : s
+ ))
+ // Call API to rename session
+ try {
+ const token = localStorage.getItem("session_token")
+ const headers = {
+ "X-Session-Token": token || "",
+ "Content-Type": "application/json"
+ }
+ await fetch(`/api/v1/chat/sessions/${sessionId}`, {
+ method: "PATCH",
+ headers,
+ body: JSON.stringify({ title: newTitle })
+ })
+ } catch (error) {
+ console.error("Failed to rename session:", error)
+ }
+ }}
+ isOpen={isSidebarOpen}
+ onToggle={() => setIsSidebarOpen(!isSidebarOpen)}
+ />
+
+ {/* Main Chat Area */}
+
+ {/* Mobile Menu Button */}
+
setIsSidebarOpen(true)}
+ className="md:hidden absolute top-4 left-4 z-10 p-2 bg-card border rounded-lg shadow-sm hover:bg-accent transition-colors"
+ aria-label="Open menu"
+ >
+
+
+
+
+
+
+
setIsSidebarOpen(true)}
+ />
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/app/developers/constants.ts b/src/app/developers/constants.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0627126ebabd3abdec06eddf8ab18ebe3a3e0cb4
--- /dev/null
+++ b/src/app/developers/constants.ts
@@ -0,0 +1,158 @@
+export const DEVELOPER_KIT_VERSION = "1.0"
+export const LAST_UPDATED = "2025-11-29"
+
+export const MASTER_SYSTEM_PROMPT = `You are AmaniQuery, an expert legal research assistant and systems architect for Kenyan law. Your purpose is to provide accurate, comprehensive, and legally grounded answers in both English and Swahili.
+
+### CORE INSTRUCTIONS
+1. **Output Format**: You must ALWAYS respond using a strict JSON-like structure with two distinct fields:
+ - \`reasoning_content\`: Internal chain-of-thought, planning, and analysis. This is NEVER shown to the user.
+ - \`content\`: The final, polished user-facing response.
+
+2. **Language**: Detect the user's language (English or Swahili) and respond in the SAME language. Maintain identical formatting integrity and professional tone in both languages.
+
+3. **Modes of Operation**: Automatically detect the intent and apply the correct mode:
+ - **Standard Chat**: For casual greetings, general questions, or simple clarifications.
+ - **Hybrid RAG**: For specific legal questions requiring retrieval of cases, statutes, or constitution.
+ - **Research Agent**: For complex, multi-step research tasks or report generation.
+
+### RESPONSE STRUCTURE (JSON-LIKE)
+\`\`\`json
+{
+ "reasoning_content": "Step-by-step analysis, search strategy, and synthesis of information...",
+ "content": "The final formatted response..."
+}
+\`\`\`
+
+### FORMATTING RULES (Apply to 'content' field)
+- **Markdown**: Use generous whitespace. Paragraphs must be ≤80 words.
+- **Headings**: Use max 4 levels (H1-H4).
+- **Styling**: Use **bold** for emphasis, *italics* for definitions/nuance.
+- **Lists**: Use bullet points or numbered lists for readability. Avoid excessive nesting.
+- **Tables**: Use Markdown tables for comparisons and structured data.
+- **Separators**: Use horizontal rules (\`---\`) to visually separate distinct sections.
+- **Blockquotes**: Use \`>\` for quoting legal text or external sources.
+- **Code**: Use code blocks for statutes or specific clauses if needed.
+- **Math**: Use LaTeX \`\\( \\)\` for inline and \`\\[ \\]\` for block equations.`
+
+export const HYBRID_RAG_PROMPT = `You are in Hybrid RAG Mode. Your "content" field MUST follow this precise structure:
+
+## Key Sources
+- [Summary of source 1] (Source Name)
+- [Summary of source 2] (Source Name)
+
+## Analysis & Synthesis
+[Step-by-step explanation of how sources were evaluated, compared, and combined to form the answer.]
+
+## Final Answer
+[A polished, comprehensive answer to the user's query. Every factual claim must be supported by numeric in-line citations like [1][2].]
+
+## References
+1. [Title](URL) - Author/Publication, Date
+2. [Title](URL) - Author/Publication, Date`
+
+export const LEGAL_SPECIALIST_PROMPT = `You are the Legal Content Specialist for AmaniQuery. Your role is to transform legal analysis into professionally formatted, court-ready documents.
+
+### FORMATTING STANDARDS
+1. **Citations**: Use Bluebook (21st Ed.) or standard Kenyan legal citation style (e.g., *Republic v. John Doe* [2025] eKLR).
+ - **Statutes**: *The Constitution of Kenya, 2010, Art. 43(1)(b)*.
+ - **Cases**: *Okiya Omtatah Okoiti v. Cabinet Secretary, National Treasury & 3 Others* [2023] eKLR.
+ - **Hyperlinks**: ALL citations must be hyperlinked to their source (Kenya Law Reports, Parliament, etc.).
+
+2. **Emphasis**:
+ - Use \`> blockquotes\` for direct excerpts from statutes or judgments.
+ - Use **bold** for key legal principles or holding phrases within the text.
+ - NEVER use bold for entire paragraphs.
+
+3. **Structure**:
+ - **Case Analysis**: Follow strict **IRAC** (Issue, Rule, Analysis, Conclusion) or **FIRAC** (Facts, Issue, Rule, Analysis, Conclusion) structure.
+ - **Arguments**: Use dedicated headings for opposing views (e.g., \`### Arguments for the Petitioner\`, \`### Arguments for the Respondent\`).
+ - **Statutory Comparison**: Use Markdown tables to compare provisions (e.g., \`| Old Act | New Bill | Implication |\`).
+
+4. **Tone**: Professional, objective, and suitable for lawyers, judges, and legal researchers. Avoid colloquialisms.
+
+### OUTPUT TEMPLATE
+\`\`\`markdown
+### Case Brief: [Case Name]
+
+**Citation:** [Link to Case]
+
+#### Facts
+[Brief summary of material facts]
+
+#### Issue
+[The legal question to be decided]
+
+#### Rule
+> [Key statutory provision or precedent]
+
+#### Analysis
+[Application of rule to facts. Use **bold** for key reasoning.]
+
+#### Conclusion
+[The court's holding and order]
+\`\`\``
+
+export const NEWS_SPECIALIST_PROMPT = `You are the News & Parliamentary Records Specialist. Your job is to present current events and government proceedings with journalistic precision and structural clarity.
+
+### FORMATTING RULES
+1. **Speaker Formatting**: ALWAYS format speakers in Hansard/Transcripts as:
+ - \`**Hon. [Name] ([Role/Constituency]):** "Quote..."\`
+ - Example: **Hon. Kimani Ichung'wah (Majority Leader):** "This Bill is timely..."
+
+2. **Direct Quotes**:
+ - Use \`> blockquotes\` for all direct speech or excerpts.
+ - Attribute EVERY quote with source and date: \`> "Quote text..." (Daily Nation, 29 Nov 2025)\`
+
+3. **Vote Results**:
+ - Use Markdown tables for all voting outcomes.
+ - Columns: \`| Member/Party | Vote (Yes/No/Abstain) | Remarks |\`
+
+4. **Chronology**:
+ - Use strict chronological order for event summaries.
+ - Use timestamps/dates as sub-bullets: \`* **10:00 AM:** Session commenced.\`
+
+5. **Attribution**:
+ - Every factual claim must have an attribution tag: "According to [Source], [Date]..."
+ - Link the source immediately if possible.
+
+### OUTPUT TEMPLATE (Parliamentary)
+\`\`\`markdown
+### Session Summary: [Date]
+
+#### Key Speakers
+* **Hon. Jane Kamau (Minister of Finance):** Discussed the Finance Bill...
+
+#### Debate Highlights
+> "We cannot overtax the common mwananchi..." (Hon. Kamau, Hansard 14:30)
+
+#### Voting Outcome
+| Party | Ayes | Nays | Abstain |
+| :--- | :---: | :---: | :---: |
+| UDA | 140 | 0 | 5 |
+| ODM | 0 | 85 | 2 |
+\`\`\``
+
+export const PYDANTIC_VALIDATOR = `from pydantic import BaseModel, Field, validator
+import re
+
+class AmaniQueryResponse(BaseModel):
+ reasoning_content: str = Field(..., description="Internal chain-of-thought")
+ content: str = Field(..., description="Final user-facing response in Markdown")
+
+ @validator('content')
+ def validate_markdown_structure(cls, v):
+ if re.search(r'^#####\\s', v, re.MULTILINE): raise ValueError("Headings > H4")
+ if re.search(r'\\$.*?\\$', v): raise ValueError("Use LaTeX \\\\( \\\\) not $")
+ return v
+
+ @validator('content')
+ def validate_citations(cls, v):
+ if re.search(r'\\[\\d+\\]', v) and "## References" not in v:
+ raise ValueError("Citations found but References missing")
+ return v`
+
+export const GOLDEN_TEST_CASE = `**Query:** "What is the capital of Kenya?"
+**Expected:** "The capital of Kenya is **Nairobi**."
+
+**Query:** "What does Article 43 say?"
+**Expected:** (Hybrid RAG structure with ## Key Sources, ## Analysis, ## Final Answer, ## References)`
diff --git a/src/app/developers/page.tsx b/src/app/developers/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b3e9ecd4c464df072ca0f1e230ef8c037034b59c
--- /dev/null
+++ b/src/app/developers/page.tsx
@@ -0,0 +1,339 @@
+"use client"
+
+import { useState } from "react"
+import Link from "next/link"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import {
+ Code,
+ Book,
+ Globe,
+ Zap,
+ Shield,
+ Database,
+ FileText,
+ ExternalLink,
+ Copy,
+ Check,
+ ArrowLeft,
+ Terminal,
+ Cpu,
+ Scale,
+ Newspaper,
+ CheckCircle2
+} from "lucide-react"
+import { AnimatedIDE } from "@/components/animated-ide"
+import { ChatStreamDemo } from "@/components/chat-stream-demo"
+import {
+ DEVELOPER_KIT_VERSION,
+ LAST_UPDATED,
+ MASTER_SYSTEM_PROMPT,
+ HYBRID_RAG_PROMPT,
+ LEGAL_SPECIALIST_PROMPT,
+ NEWS_SPECIALIST_PROMPT,
+ PYDANTIC_VALIDATOR,
+ GOLDEN_TEST_CASE
+} from "./constants"
+
+const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+
+export default function DevelopersPage() {
+ const [copiedSection, setCopiedSection] = useState(null)
+
+ const copyToClipboard = (text: string, id: string) => {
+ navigator.clipboard.writeText(text)
+ setCopiedSection(id)
+ setTimeout(() => setCopiedSection(null), 2000)
+ }
+
+ const CodeBlock = ({ code, id, language = "text" }: { code: string, id: string, language?: string }) => (
+
+
+ copyToClipboard(code, id)}
+ >
+ {copiedSection === id ? (
+
+ ) : (
+
+ )}
+
+
+
+ {code}
+
+
+ )
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+ Back to Home
+
+
+
+
+
+
Developer Hub
+ v{DEVELOPER_KIT_VERSION}
+
+
+ Build compliant legal AI applications with the AmaniQuery Developer Kit.
+ Access system prompts, validators, and testing resources.
+
+
+
+
+
+
+ {/* Interactive Demos */}
+
+
+
+ {/* Main Content */}
+
+
+
+
+
+
+ System Prompts
+
+
+
+ Specialist Agents
+
+
+
+ Validators
+
+
+
+ Testing
+
+
+
+ {/* System Prompts Tab */}
+
+
+
+
+
+ Master System Prompt
+
+
+ The core instruction set for the AmaniQuery engine. Handles intent detection,
+ language switching (English/Swahili), and response formatting.
+
+
+
+
+
+
+
+
+
+
+
+ Hybrid RAG Mode
+
+
+ Specialized prompt for retrieving and synthesizing legal sources.
+ Enforces citation rules and structured analysis.
+
+
+
+
+
+
+
+
+ {/* Specialist Agents Tab */}
+
+
+
+
+
+ Legal Content Specialist
+
+
+ Generates court-ready legal analysis with Bluebook citations,
+ IRAC structure, and statutory comparisons.
+
+
+
+
+
+
+
+
+
+
+
+ News & Parliamentary Specialist
+
+
+ Formats Hansard records, voting outcomes, and news summaries
+ with journalistic precision and attribution.
+
+
+
+
+
+
+
+
+ {/* Validators Tab */}
+
+
+
+
+
+ Pydantic Response Validator
+
+
+ Python code to validate the structural integrity of agent responses,
+ ensuring no reasoning leakage and proper citation format.
+
+
+
+
+
+
+
+
+ {/* Testing Tab */}
+
+
+
+
+
+ Golden Test Cases
+
+
+ Standardized Q&A pairs for regression testing the agent's performance.
+
+
+
+
+
+
+
+
+
+
+ {/* Sidebar */}
+
+
+
+
+ )
+}
diff --git a/src/app/favicon.ico b/src/app/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..6b09b5bc56716e3f0bfb3257d633719e2847716d
--- /dev/null
+++ b/src/app/favicon.ico
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3c13aaba0b648bb3f2ae6be3d5771cc4240a37b072cca6ca6ca54ca83042f3c5
+size 1016899
diff --git a/src/app/globals.css b/src/app/globals.css
new file mode 100644
index 0000000000000000000000000000000000000000..f553213549d775738a5c0374d4883161c51d5948
--- /dev/null
+++ b/src/app/globals.css
@@ -0,0 +1,536 @@
+@import "tailwindcss";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-chart-5: var(--chart-5);
+ --color-chart-4: var(--chart-4);
+ --color-chart-3: var(--chart-3);
+ --color-chart-2: var(--chart-2);
+ --color-chart-1: var(--chart-1);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-destructive: var(--destructive);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+}
+
+:root {
+ --radius: 0.625rem;
+ --background: #F8F6F2;
+ --foreground: #1E1E1E;
+ --card: #FFFFFF;
+ --card-foreground: #1E1E1E;
+ --popover: #FFFFFF;
+ --popover-foreground: #1E1E1E;
+ --primary: #0A4A8F;
+ --primary-foreground: #FFFFFF;
+ --secondary: #F8F6F2;
+ --secondary-foreground: #1E1E1E;
+ --muted: #F8F6F2;
+ --muted-foreground: #6B7280;
+ --accent: #B89B4E;
+ --accent-foreground: #FFFFFF;
+ --destructive: #dc2626;
+ --border: #e5e7eb;
+ --input: #e5e7eb;
+ --ring: #0A4A8F;
+ --chart-1: #0A4A8F;
+ --chart-2: #3B82F6;
+ --chart-3: #B89B4E;
+ --chart-4: #6B7280;
+ --chart-5: #1E1E1E;
+ --sidebar: #FFFFFF;
+ --sidebar-foreground: #1E1E1E;
+ --sidebar-primary: #0A4A8F;
+ --sidebar-primary-foreground: #FFFFFF;
+ --sidebar-accent: #B89B4E;
+ --sidebar-accent-foreground: #FFFFFF;
+ --sidebar-border: #e5e7eb;
+ --sidebar-ring: #0A4A8F;
+}
+
+.dark {
+ --background: #121418;
+ --foreground: #E5E7EB;
+ --card: #1E2024;
+ --card-foreground: #E5E7EB;
+ --popover: #1E2024;
+ --popover-foreground: #E5E7EB;
+ --primary: #3B82F6;
+ --primary-foreground: #FFFFFF;
+ --secondary: #1E2024;
+ --secondary-foreground: #E5E7EB;
+ --muted: #1E2024;
+ --muted-foreground: #9CA3AF;
+ --accent: #DDAF3B;
+ --accent-foreground: #FFFFFF;
+ --destructive: #ef4444;
+ --border: rgba(255, 255, 255, 0.1);
+ --input: rgba(255, 255, 255, 0.15);
+ --ring: #3B82F6;
+ --chart-1: #3B82F6;
+ --chart-2: #0A4A8F;
+ --chart-3: #DDAF3B;
+ --chart-4: #9CA3AF;
+ --chart-5: #E5E7EB;
+ --sidebar: #1E2024;
+ --sidebar-foreground: #E5E7EB;
+ --sidebar-primary: #3B82F6;
+ --sidebar-primary-foreground: #FFFFFF;
+ --sidebar-accent: #DDAF3B;
+ --sidebar-accent-foreground: #FFFFFF;
+ --sidebar-border: rgba(255, 255, 255, 0.1);
+ --sidebar-ring: #3B82F6;
+}
+
+/* Savanna Tech Theme */
+[data-theme="savanna-tech"] {
+ --background: #F9FAFB;
+ --foreground: #111827;
+ --card: #FFFFFF;
+ --card-foreground: #111827;
+ --popover: #FFFFFF;
+ --popover-foreground: #111827;
+ --primary: #1E6043;
+ --primary-foreground: #FFFFFF;
+ --secondary: #F9FAFB;
+ --secondary-foreground: #111827;
+ --muted: #F9FAFB;
+ --muted-foreground: #64748B;
+ --accent: #C45A3A;
+ --accent-foreground: #FFFFFF;
+ --destructive: #dc2626;
+ --border: #e5e7eb;
+ --input: #e5e7eb;
+ --ring: #1E6043;
+ --chart-1: #1E6043;
+ --chart-2: #34D399;
+ --chart-3: #C45A3A;
+ --chart-4: #64748B;
+ --chart-5: #111827;
+ --sidebar: #FFFFFF;
+ --sidebar-foreground: #111827;
+ --sidebar-primary: #1E6043;
+ --sidebar-primary-foreground: #FFFFFF;
+ --sidebar-accent: #C45A3A;
+ --sidebar-accent-foreground: #FFFFFF;
+ --sidebar-border: #e5e7eb;
+ --sidebar-ring: #1E6043;
+}
+
+[data-theme="savanna-tech"].dark {
+ --background: #0A0F1A;
+ --foreground: #D1D5DB;
+ --card: #111827;
+ --card-foreground: #D1D5DB;
+ --popover: #111827;
+ --popover-foreground: #D1D5DB;
+ --primary: #34D399;
+ --primary-foreground: #FFFFFF;
+ --secondary: #111827;
+ --secondary-foreground: #D1D5DB;
+ --muted: #111827;
+ --muted-foreground: #94A3B8;
+ --accent: #F07A5A;
+ --accent-foreground: #FFFFFF;
+ --destructive: #ef4444;
+ --border: rgba(255, 255, 255, 0.1);
+ --input: rgba(255, 255, 255, 0.15);
+ --ring: #34D399;
+ --chart-1: #34D399;
+ --chart-2: #1E6043;
+ --chart-3: #F07A5A;
+ --chart-4: #94A3B8;
+ --chart-5: #D1D5DB;
+ --sidebar: #111827;
+ --sidebar-foreground: #D1D5DB;
+ --sidebar-primary: #34D399;
+ --sidebar-primary-foreground: #FFFFFF;
+ --sidebar-accent: #F07A5A;
+ --sidebar-accent-foreground: #FFFFFF;
+ --sidebar-border: rgba(255, 255, 255, 0.1);
+ --sidebar-ring: #34D399;
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+
+ /* Mobile touch improvements */
+ @media (max-width: 768px) {
+ /* Ensure minimum touch target sizes */
+ button, [role="button"], input[type="submit"], input[type="button"] {
+ min-height: 44px;
+ min-width: 44px;
+ }
+
+ /* Improve tap targets for interactive elements */
+ a, button, [role="button"], input, textarea, select {
+ touch-action: manipulation;
+ }
+
+ /* Better text selection on mobile */
+ * {
+ -webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
+ -webkit-touch-callout: none;
+ -webkit-user-select: text;
+ user-select: text;
+ }
+
+ /* Prevent zoom on input focus on iOS */
+ input[type="text"], input[type="email"], input[type="password"], input[type="search"], textarea {
+ font-size: 16px;
+ }
+ }
+
+ /* Smooth scrolling for better mobile experience */
+ html {
+ scroll-behavior: smooth;
+ }
+
+ /* Better focus styles for accessibility */
+ *:focus-visible {
+ outline: 2px solid hsl(var(--ring));
+ outline-offset: 2px;
+ }
+}
+
+/* Hide all scrollbars globally */
+::-webkit-scrollbar {
+ display: none;
+ width: 0;
+ height: 0;
+}
+
+::-webkit-scrollbar-track {
+ display: none;
+}
+
+::-webkit-scrollbar-thumb {
+ display: none;
+}
+
+::-webkit-scrollbar-corner {
+ display: none;
+}
+
+/* Firefox scrollbar - hide */
+* {
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+/* Combined Gradient Backgrounds */
+.gradient-judicial-savanna {
+ background: linear-gradient(135deg,
+ hsl(210 40% 15%) 0%,
+ hsl(210 60% 25%) 25%,
+ hsl(25 45% 35%) 50%,
+ hsl(165 35% 20%) 75%,
+ hsl(210 40% 15%) 100%);
+}
+
+.gradient-savanna-judicial {
+ background: linear-gradient(135deg,
+ hsl(165 35% 20%) 0%,
+ hsl(25 45% 35%) 25%,
+ hsl(210 60% 25%) 50%,
+ hsl(165 35% 20%) 75%,
+ hsl(25 45% 35%) 100%);
+}
+
+.gradient-blended-primary {
+ background: linear-gradient(135deg,
+ hsl(var(--primary)) 0%,
+ hsl(var(--accent)) 50%,
+ hsl(var(--primary)) 100%);
+}
+
+.gradient-blended-secondary {
+ background: linear-gradient(135deg,
+ hsl(var(--accent)) 0%,
+ hsl(var(--primary)) 50%,
+ hsl(var(--accent)) 100%);
+}
+
+/* Gradient Animation */
+@keyframes gradient-x {
+ 0%, 100% {
+ background-size: 200% 200%;
+ background-position: left center;
+ }
+ 50% {
+ background-size: 200% 200%;
+ background-position: right center;
+ }
+}
+
+.animate-gradient-x {
+ animation: gradient-x 3s ease infinite;
+ background-size: 200% 200%;
+}
+
+/* AmaniQuery Message Animations */
+@keyframes message-slide-in {
+ 0% {
+ opacity: 0;
+ transform: translateY(20px) scale(0.95);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+@keyframes thinking-pulse {
+ 0%, 100% {
+ opacity: 0.6;
+ transform: scale(1);
+ }
+ 50% {
+ opacity: 1;
+ transform: scale(1.05);
+ }
+}
+
+@keyframes citation-glow {
+ 0%, 100% {
+ box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
+ }
+ 50% {
+ box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
+ }
+}
+
+@keyframes typing-indicator {
+ 0%, 60%, 100% {
+ transform: translateY(0);
+ opacity: 0.7;
+ }
+ 30% {
+ transform: translateY(-10px);
+ opacity: 1;
+ }
+}
+
+/* Message Animation Classes */
+.animate-message-in {
+ animation: message-slide-in 0.4s ease-out forwards;
+}
+
+.animate-thinking {
+ animation: thinking-pulse 2s ease-in-out infinite;
+}
+
+.animate-citation-hover {
+ animation: citation-glow 1.5s ease-in-out infinite;
+}
+
+.animate-typing {
+ animation: typing-indicator 1.4s ease-in-out infinite;
+}
+
+/* Smooth transitions for interactive elements */
+.transition-smooth {
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.transition-message {
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.transition-panel {
+ transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+/* Hover effects */
+.hover-lift:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
+}
+
+.hover-glow:hover {
+ box-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
+}
+
+/* Thinking process animation */
+@keyframes thinking-expand {
+ 0% {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.animate-thinking-expand {
+ animation: thinking-expand 0.3s ease-out forwards;
+}
+
+/* Source panel animations */
+@keyframes source-slide-down {
+ 0% {
+ opacity: 0;
+ transform: translateY(-20px);
+ max-height: 0;
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ max-height: 500px;
+ }
+}
+
+.animate-source-slide {
+ animation: source-slide-down 0.4s ease-out forwards;
+}
+
+/* Input field animations */
+@keyframes input-focus {
+ 0% {
+ transform: scale(1);
+ }
+ 50% {
+ transform: scale(1.02);
+ }
+ 100% {
+ transform: scale(1);
+ }
+}
+
+.animate-input-focus {
+ animation: input-focus 0.2s ease-out;
+}
+
+/* Citation tooltip animation */
+@keyframes tooltip-appear {
+ 0% {
+ opacity: 0;
+ transform: translateY(10px) scale(0.9);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+.animate-tooltip {
+ animation: tooltip-appear 0.2s ease-out forwards;
+}
+
+/* Action buttons animation */
+@keyframes action-fade-in {
+ 0% {
+ opacity: 0;
+ transform: translateX(10px);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+.animate-action-in {
+ animation: action-fade-in 0.2s ease-out forwards;
+}
+
+/* Staggered animations for multiple elements */
+.stagger-1 { animation-delay: 0.1s; }
+.stagger-2 { animation-delay: 0.2s; }
+.stagger-3 { animation-delay: 0.3s; }
+.stagger-4 { animation-delay: 0.4s; }
+.stagger-5 { animation-delay: 0.5s; }
+
+/* Custom scrollbar for better UX */
+.custom-scrollbar {
+ scrollbar-width: thin;
+ scrollbar-color: var(--primary) transparent;
+}
+
+.custom-scrollbar::-webkit-scrollbar {
+ display: block;
+ width: 6px;
+ height: 6px;
+}
+
+.custom-scrollbar::-webkit-scrollbar-track {
+ display: block;
+ background: transparent;
+}
+
+.custom-scrollbar::-webkit-scrollbar-thumb {
+ display: block;
+ background-color: var(--primary);
+ border-radius: 3px;
+}
+
+.custom-scrollbar::-webkit-scrollbar-thumb:hover {
+ background-color: var(--accent);
+}
+
+/* Loading shimmer effect */
+@keyframes shimmer {
+ 0% {
+ background-position: -200% 0;
+ }
+ 100% {
+ background-position: 200% 0;
+ }
+}
+
+.animate-shimmer {
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ rgba(255, 255, 255, 0.4),
+ transparent
+ );
+ background-size: 200% 100%;
+ animation: shimmer 1.5s infinite;
+}
+
+/* Pulse animation for thinking states */
+@keyframes gentle-pulse {
+ 0%, 100% {
+ opacity: 0.6;
+ }
+ 50% {
+ opacity: 0.8;
+ }
+}
+
+.animate-gentle-pulse {
+ animation: gentle-pulse 2s ease-in-out infinite;
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a163a51d0739f9dca6880390477a713863c9003e
--- /dev/null
+++ b/src/app/layout.tsx
@@ -0,0 +1,46 @@
+import type { Metadata } from "next";
+import { Geist, Geist_Mono } from "next/font/google";
+import "./globals.css";
+import { ThemeProvider } from "@/components/theme-provider";
+import { Toaster } from "sonner";
+import { AuthProvider } from "@/lib/auth-context";
+import { Analytics } from "@vercel/analytics/react";
+import { SpeedInsights } from "@vercel/speed-insights/next";
+
+const geistSans = Geist({
+ variable: "--font-geist-sans",
+ subsets: ["latin"],
+});
+
+const geistMono = Geist_Mono({
+ variable: "--font-geist-mono",
+ subsets: ["latin"],
+});
+
+export const metadata: Metadata = {
+ title: "AmaniQuery - RAG-Powered Legal & News Intelligence",
+ description: "AI-powered platform for Kenyan legal, parliamentary, and news intelligence with constitutional alignment analysis",
+};
+
+export default function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..843f33402743906eb39e044b65f3d81ddce99c5b
--- /dev/null
+++ b/src/app/page.tsx
@@ -0,0 +1,502 @@
+"use client"
+
+import { useState, useEffect, Suspense } from "react"
+import { useRouter, useSearchParams } from "next/navigation"
+import Link from "next/link"
+import { useAuth } from "@/lib/auth-context"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { ThemeToggle } from "@/components/theme-toggle"
+import NotificationSubscriptionDialog from "@/components/NotificationSubscriptionDialog"
+import {
+ Brain,
+ Scale,
+ Newspaper,
+ Github,
+ Star,
+ GitFork,
+ ExternalLink,
+ Sparkles,
+ Users,
+ Database,
+ MessageSquare,
+ Shield,
+ Zap,
+ Bell,
+ LogIn,
+ UserPlus
+} from "lucide-react"
+
+// Component that uses useSearchParams - must be wrapped in Suspense
+function RedirectHandler() {
+ const { isAuthenticated, isAdmin, loading } = useAuth()
+ const router = useRouter()
+ const searchParams = useSearchParams()
+ const shouldRedirect = !searchParams.get("noredirect")
+
+ useEffect(() => {
+ if (!loading && isAuthenticated && shouldRedirect) {
+ // Redirect authenticated users based on role
+ if (isAdmin) {
+ router.push("/admin")
+ } else {
+ router.push("/chat")
+ }
+ }
+ }, [isAuthenticated, isAdmin, loading, router, shouldRedirect])
+
+ return null
+}
+
+export default function LandingPage() {
+ const [isDialogOpen, setIsDialogOpen] = useState(false)
+ const { isAuthenticated, isAdmin, loading } = useAuth()
+
+ // Show loading state while checking auth
+ if (loading) {
+ return (
+
+ )
+ }
+
+ return (
+ <>
+
+
+
+
+ {/* Animated Background Elements */}
+
+
+ {/* Floating Icons */}
+
+
+
+
+
+
+
+
+
+
+ {/* Header */}
+
+
+
+
+
+ AmaniQuery
+
+
+
+
+
+ Blog
+
+
+
+
+
+ Developers
+
+
+ {!isAuthenticated && (
+ <>
+
+
+
+ Sign In
+
+
+
+
+
+ Get Started
+
+
+ >
+ )}
+
+
+
+ GitHub
+
+
+
+
+
+
+ {/* Hero Section */}
+
+
+
+
+
+ AI-Powered Legal Intelligence
+
+
+ Kenya's AI Legal Assistant
+
+
+ Experience the future of legal research with AmaniQuery - an intelligent RAG system
+ that combines constitutional law, parliamentary proceedings, and news analysis
+ to provide accurate, verifiable answers about Kenyan governance.
+
+
+
+
+
+ {/* Feature Cards */}
+
+
+
+
+ Constitutional Analysis
+
+
+
+ Deep analysis of Kenyan constitutional law with AI-powered alignment checking
+ between bills and the supreme law.
+
+
+
+
+
+
+
+ Parliamentary Intelligence
+
+
+
+ Real-time access to parliamentary proceedings, bills, and debates
+ with intelligent summarization and insights.
+
+
+
+
+
+
+
+ Verified Sources
+
+
+
+ Every answer backed by verifiable sources from official government
+ publications and reputable news outlets.
+
+
+
+
+
+ {/* News Notifications Section */}
+
+
+
+
+
+ Stay Updated with News Notifications
+
+
+
+
+ Get instant notifications via WhatsApp or SMS when new articles are published.
+ Choose your preferred categories and sources to stay informed.
+
+ setIsDialogOpen(true)}
+ className="min-h-[44px] px-6 md:px-8"
+ >
+
+ Subscribe to Notifications
+
+
+
+
+
+
+
+ {/* AI Integration Section */}
+
+
+
+
+ AI Integration Across Legal Domains
+
+
+ AmaniQuery seamlessly integrates AI across multiple aspects of legal research,
+ governance, and public information access.
+
+
+
+
+
+
+
+
+
+
+
Intelligent Document Processing
+
+ Advanced NLP models process legal documents, extracting key provisions,
+ analyzing sentiment, and identifying relationships between laws.
+
+
+
+
+
+
+
+
+
+
Vector Database Integration
+
+ Semantic search capabilities powered by vector embeddings for
+ precise retrieval of relevant legal information.
+
+
+
+
+
+
+
+
+
+
Conversational AI
+
+ Natural language interface for querying complex legal topics
+ with contextual follow-up and clarification capabilities.
+
+
+
+
+
+
+
+
+
+
Constitutional Alignment
+
+ Specialized analysis comparing proposed legislation with
+ constitutional provisions for compliance verification.
+
+
+
+
+
+
+
+
+
+
+
Constitution
+
Supreme Law
+
+
+
+
Parliament
+
Live Debates
+
+
+
+
Bills
+
Legislation
+
+
+
+
+
+
AI-Powered Integration
+
+
+
+
+
+
+
+ {/* GitHub Contributions Section */}
+
+
+
+
+ Join the Open Source Movement
+
+
+ AmaniQuery is an open-source project dedicated to democratizing access to legal information
+ in Kenya. Your contributions can help build a more transparent and accessible legal system.
+
+
+
+
+
+
+
+ Contribute Code
+
+
+
+ Help improve the AI models, add new features, or enhance the user interface.
+
+
+ View Issues
+
+
+
+
+
+
+
+ Report Issues
+
+
+
+ Found a bug or have a suggestion? Help us improve by reporting issues.
+
+
+ Report Issue
+
+
+
+
+
+
+
+ Community
+
+
+
+ Join discussions, share ideas, and connect with fellow contributors.
+
+
+ Join Discussion
+
+
+
+
+
+
+
Ready to Contribute?
+
+ Whether you're a developer, legal expert, or AI enthusiast, your contributions
+ can make a real difference in improving access to legal information in Kenya.
+
+
+
+
+
+
+ {/* Footer */}
+
+
+
+ {/* Notification Subscription Dialog */}
+
setIsDialogOpen(false)}
+ />
+
+ >
+ )
+}
diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..206a40f97d1d13b1960859c46667bd41443d2e32
--- /dev/null
+++ b/src/app/profile/page.tsx
@@ -0,0 +1,497 @@
+"use client"
+
+import { useState, useEffect, useRef } from "react"
+import { useRouter } from "next/navigation"
+import { useAuth } from "@/lib/auth-context"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { AmaniSidebar } from "@/components/AmaniSidebar"
+import { ThemeToggle } from "@/components/theme-toggle"
+import { toast } from "sonner"
+import { Loader2, User, Mail, Shield, Calendar, CheckCircle, XCircle, Lock, Upload, Camera } from "lucide-react"
+import Image from "next/image"
+import { Badge } from "@/components/ui/badge"
+import type { ChatSession } from "@/components/chat/types"
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+
+export default function ProfilePage() {
+ const { user, loading: authLoading, isAuthenticated } = useAuth()
+ const router = useRouter()
+ const [isLoading, setIsLoading] = useState(false)
+ const [isChangingPassword, setIsChangingPassword] = useState(false)
+ const [sidebarOpen, setSidebarOpen] = useState(false)
+ const [profileData, setProfileData] = useState({
+ name: "",
+ email: "",
+ })
+ const [passwordData, setPasswordData] = useState({
+ currentPassword: "",
+ newPassword: "",
+ confirmPassword: "",
+ })
+ const [uploadingImage, setUploadingImage] = useState(false)
+ const fileInputRef = useRef(null)
+
+ useEffect(() => {
+ if (!authLoading && !isAuthenticated) {
+ router.push("/auth/signin?redirect=/profile")
+ }
+ }, [authLoading, isAuthenticated, router])
+
+ useEffect(() => {
+ if (user) {
+ setProfileData({
+ name: user.name || "",
+ email: user.email || "",
+ })
+ }
+ }, [user])
+
+ const handleProfileUpdate = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setIsLoading(true)
+
+ try {
+ const sessionToken = localStorage.getItem("session_token")
+ const response = await fetch(`${API_URL}/api/v1/auth/me`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Session-Token": sessionToken || "",
+ },
+ body: JSON.stringify(profileData),
+ })
+
+ if (!response.ok) {
+ const error = await response.json()
+ throw new Error(error.detail || "Failed to update profile")
+ }
+
+ toast.success("Profile updated successfully")
+ // Refresh user data
+ window.location.reload()
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to update profile")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handlePasswordChange = async (e: React.FormEvent) => {
+ e.preventDefault()
+
+ if (passwordData.newPassword !== passwordData.confirmPassword) {
+ toast.error("New passwords do not match")
+ return
+ }
+
+ if (passwordData.newPassword.length < 8) {
+ toast.error("Password must be at least 8 characters long")
+ return
+ }
+
+ setIsChangingPassword(true)
+
+ try {
+ const sessionToken = localStorage.getItem("session_token")
+ const response = await fetch(`${API_URL}/api/v1/auth/password/change`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Session-Token": sessionToken || "",
+ },
+ body: JSON.stringify({
+ current_password: passwordData.currentPassword,
+ new_password: passwordData.newPassword,
+ }),
+ })
+
+ if (!response.ok) {
+ const error = await response.json()
+ throw new Error(error.detail || "Failed to change password")
+ }
+
+ toast.success("Password changed successfully")
+ setPasswordData({
+ currentPassword: "",
+ newPassword: "",
+ confirmPassword: "",
+ })
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to change password")
+ } finally {
+ setIsChangingPassword(false)
+ }
+ }
+
+ const handleImageUpload = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0]
+ if (!file) return
+
+ // Validate file type
+ const allowedTypes = ["image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp"]
+ if (!allowedTypes.includes(file.type)) {
+ toast.error("Invalid file type. Please upload a PNG, JPEG, GIF, or WebP image.")
+ return
+ }
+
+ // Validate file size (5MB limit)
+ if (file.size > 5 * 1024 * 1024) {
+ toast.error("File size exceeds 5MB limit")
+ return
+ }
+
+ setUploadingImage(true)
+
+ try {
+ const sessionToken = localStorage.getItem("session_token")
+ const formData = new FormData()
+ formData.append("file", file)
+
+ const response = await fetch(`${API_URL}/api/v1/auth/me/profile-image`, {
+ method: "POST",
+ headers: {
+ "X-Session-Token": sessionToken || "",
+ },
+ body: formData,
+ })
+
+ if (!response.ok) {
+ const error = await response.json()
+ throw new Error(error.detail || "Failed to upload profile image")
+ }
+
+ toast.success("Profile image updated successfully")
+ // Refresh user data
+ window.location.reload()
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to upload profile image")
+ } finally {
+ setUploadingImage(false)
+ if (fileInputRef.current) {
+ fileInputRef.current.value = ""
+ }
+ }
+ }
+
+ if (authLoading) {
+ return (
+
+ )
+ }
+
+ if (!isAuthenticated) {
+ return null
+ }
+
+ return (
+
+
{}}
+ onNewSession={() => router.push("/chat")}
+ onDeleteSession={() => {}}
+ onRenameSession={() => {}}
+ isOpen={sidebarOpen}
+ onToggle={() => setSidebarOpen(!sidebarOpen)}
+ />
+
+
+
+
+
+
+
Profile Settings
+
Manage your account information and preferences
+
+
+ {/* Profile Image */}
+
+
+
+
+ Profile Picture
+
+ Upload a profile image to personalize your account
+
+
+
+
+ {user?.profile_image_url ? (
+
+
+
+ ) : (
+
+
+
+ )}
+ {uploadingImage && (
+
+
+
+ )}
+
+
+
fileInputRef.current?.click()}
+ disabled={uploadingImage}
+ >
+ {uploadingImage ? (
+ <>
+
+ Uploading...
+ >
+ ) : (
+ <>
+
+ {user?.profile_image_url ? "Change Image" : "Upload Image"}
+ >
+ )}
+
+ {user?.profile_image_url && (
+
{
+ try {
+ const sessionToken = localStorage.getItem("session_token")
+ const response = await fetch(`${API_URL}/api/v1/auth/me`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Session-Token": sessionToken || "",
+ },
+ body: JSON.stringify({ profile_image_url: null }),
+ })
+ if (response.ok) {
+ toast.success("Profile image removed")
+ window.location.reload()
+ }
+ } catch {
+ toast.error("Failed to remove profile image")
+ }
+ }}
+ className="text-destructive"
+ >
+ Remove Image
+
+ )}
+
+
+
+
+ {/* Account Information */}
+
+
+
+
+ Account Information
+
+ Update your personal information
+
+
+
+
+
+
+ {/* Account Status */}
+
+
+
+
+ Account Status
+
+
+
+
+
+
+ Email Verification
+
+ {user?.email_verified ? (
+
+
+ Verified
+
+ ) : (
+
+
+ Not Verified
+
+ )}
+
+
+
+
+
+ Account Status
+
+
+ {user?.status || "Unknown"}
+
+
+
+
+
+
+ Member Since
+
+
+ {user?.created_at ? new Date(user.created_at).toLocaleDateString() : "N/A"}
+
+
+ {user?.roles && user.roles.length > 0 && (
+ <>
+
+
+
+
+ Roles
+
+
+ {user.roles.map((role) => (
+
+ {role}
+
+ ))}
+
+
+ >
+ )}
+
+
+
+ {/* Change Password */}
+
+
+
+
+ Change Password
+
+ Update your password to keep your account secure
+
+
+
+
+
+
+
+
+ )
+}
+
diff --git a/src/app/shared/[id]/page.tsx b/src/app/shared/[id]/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..34605b3a407171439b5af7cb2f57e0b34e0f2049
--- /dev/null
+++ b/src/app/shared/[id]/page.tsx
@@ -0,0 +1,143 @@
+"use client"
+
+import { useEffect, useState, useRef } from "react"
+import { useParams } from "next/navigation"
+import Link from "next/link"
+import { AmaniMessageList } from "@/components/chat/AmaniMessageList"
+import { Message } from "@/components/chat/types"
+import { Loader2 } from "lucide-react"
+import { toast } from "sonner"
+
+export default function SharedChatPage() {
+ const params = useParams()
+ const id = params.id as string
+ const [messages, setMessages] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [title, setTitle] = useState("")
+ const [showSources, setShowSources] = useState(false)
+
+ // Refs for auto-scrolling
+ const messagesContainerRef = useRef(null)
+ const messagesEndRef = useRef(null)
+
+ useEffect(() => {
+ const fetchSharedSession = async () => {
+ try {
+ const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/api/v1/chat/shared/${id}`)
+ if (!response.ok) {
+ throw new Error("Failed to load shared chat")
+ }
+ const data = await response.json()
+ setMessages(data.messages)
+ setTitle(data.title)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "An error occurred")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ if (id) {
+ fetchSharedSession()
+ }
+ }, [id])
+
+ // No-op handlers for read-only view
+ const noOp = () => {}
+
+ const handleCopy = async (content: string) => {
+ try {
+ await navigator.clipboard.writeText(content)
+ toast.success("Copied to clipboard")
+ } catch {
+ toast.error("Failed to copy")
+ }
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/src/app/voice/page.tsx b/src/app/voice/page.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d662a8086a93a6ecb8157b1e7a0ae327abc56144
--- /dev/null
+++ b/src/app/voice/page.tsx
@@ -0,0 +1,90 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { useRouter } from "next/navigation"
+import { VoiceChat } from "@/components/voice-chat"
+import { ThemeToggle } from "@/components/theme-toggle"
+import { AmaniSidebar } from "@/components/AmaniSidebar"
+import { useAuth } from "@/lib/auth-context"
+import type { ChatSession } from "@/components/chat/types"
+
+export default function VoicePage() {
+ const { isAuthenticated, loading } = useAuth()
+ const router = useRouter()
+
+ // Sidebar state - voice page doesn't use chat history but needs sidebar props
+ const [sidebarOpen, setSidebarOpen] = useState(false)
+ const [chatHistory] = useState([])
+
+ useEffect(() => {
+ if (!loading && !isAuthenticated) {
+ router.push("/auth/signin?redirect=/voice")
+ }
+ }, [isAuthenticated, loading, router])
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (!isAuthenticated) {
+ return null
+ }
+
+ // No-op handlers for sidebar (voice page doesn't use chat sessions)
+ const handleSessionSelect = () => {}
+ const handleNewSession = () => router.push("/chat")
+ const handleDeleteSession = () => {}
+ const handleRenameSession = () => {}
+
+ return (
+
+ {/* AmaniSidebar */}
+
setSidebarOpen(!sidebarOpen)}
+ />
+
+
+
+ {/* Background Elements */}
+
+
+
+ {/* Grid Pattern */}
+
+
+
+ {/* Header */}
+
+
+
+ V
+
+
+ VoiceAgent
+
+
+
+
+
+
+
+ {/* Main Content */}
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/AgentMonitoring.tsx b/src/components/AgentMonitoring.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..12dac1e088e0e65469061e24ebc2eb062857602d
--- /dev/null
+++ b/src/components/AgentMonitoring.tsx
@@ -0,0 +1,811 @@
+/**
+ * Agent Monitoring Component
+ *
+ * Standalone component for monitoring agent performance
+ *
+ */
+
+"use client"
+
+import { useState, useEffect } from "react"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { Textarea } from "@/components/ui/textarea"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import ThinkingProcess from "@/components/ThinkingProcess"
+import {
+ Activity,
+ AlertTriangle,
+ BarChart3,
+ Brain,
+ CheckCircle,
+ Clock,
+ RefreshCw,
+ Search,
+ TrendingUp,
+ Users,
+ XCircle,
+ Zap,
+ Flag
+} from "lucide-react"
+import { cn } from "@/lib/utils"
+
+// Types (same as before)
+interface ThoughtStep {
+ step: number
+ action: string
+ observation: string
+ reasoning: string
+ duration_ms?: number
+ confidence?: number
+}
+
+interface ReasoningPath {
+ query: string
+ thoughts: ThoughtStep[]
+ total_duration_ms: number
+ final_conclusion: string
+}
+
+interface QueryLog {
+ id: string
+ timestamp: string
+ query: string
+ persona: "wanjiku" | "wakili" | "mwanahabari"
+ intent: "news" | "law" | "hybrid" | "general"
+ confidence: number
+ response_time_ms: number
+ evidence_count: number
+ reasoning_steps: number
+ human_review_required: boolean
+ agent_path: string[]
+ quality_issues: string[]
+ reasoning_path?: ReasoningPath | string
+ user_feedback?: "positive" | "negative" | null
+}
+
+interface AgentMetrics {
+ total_queries: number
+ avg_confidence: number
+ avg_response_time_ms: number
+ human_review_rate: number
+ persona_distribution: {
+ wanjiku: number
+ wakili: number
+ mwanahabari: number
+ }
+ intent_distribution: {
+ news: number
+ law: number
+ hybrid: number
+ general: number
+ }
+ confidence_buckets: {
+ low: number
+ medium: number
+ high: number
+ }
+}
+
+interface ReviewQueueItem extends QueryLog {
+ review_reason: string
+ priority: "low" | "medium" | "high"
+}
+
+export function AgentMonitoring() {
+ const [metrics, setMetrics] = useState(null)
+ const [queryLogs, setQueryLogs] = useState([])
+ const [reviewQueue, setReviewQueue] = useState([])
+ const [selectedLog, setSelectedLog] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [searchQuery, setSearchQuery] = useState("")
+ const [filterPersona, setFilterPersona] = useState("all")
+ const [filterConfidence, setFilterConfidence] = useState("all")
+
+ // Fetch functions
+ const fetchMetrics = async () => {
+ try {
+ const response = await fetch("/api/admin/agent-metrics?days=30")
+ if (!response.ok) {
+ console.error("Failed to fetch metrics:", response.statusText)
+ setMetrics(null)
+ return
+ }
+ const data = await response.json()
+ setMetrics(data)
+ } catch (error) {
+ console.error("Error fetching metrics:", error)
+ setMetrics(null)
+ }
+ }
+
+ const fetchQueryLogs = async () => {
+ setLoading(true)
+ try {
+ const response = await fetch("/api/admin/query-logs?limit=100&offset=0")
+ if (!response.ok) {
+ console.error("Failed to fetch query logs:", response.statusText)
+ setQueryLogs([])
+ return
+ }
+ const data = await response.json()
+ setQueryLogs(data.logs || [])
+ } catch (error) {
+ console.error("Error fetching query logs:", error)
+ setQueryLogs([])
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const fetchReviewQueue = async () => {
+ try {
+ const response = await fetch("/api/admin/review-queue")
+ if (!response.ok) {
+ console.error("Failed to fetch review queue:", response.statusText)
+ setReviewQueue([])
+ return
+ }
+ const data = await response.json()
+ setReviewQueue(data.queue || [])
+ } catch (error) {
+ console.error("Error fetching review queue:", error)
+ setReviewQueue([])
+ }
+ }
+
+ const handleReviewApprove = async (logId: string) => {
+ try {
+ const response = await fetch(`/api/admin/review-queue/${logId}/approve`, { method: "POST" })
+ if (!response.ok) {
+ throw new Error(`Failed to approve: ${response.statusText}`)
+ }
+ setReviewQueue(reviewQueue.filter(item => item.id !== logId))
+ // Refresh metrics to reflect the approval
+ fetchMetrics()
+ } catch (error) {
+ console.error("Error approving review:", error)
+ alert("Failed to approve query. Please try again.")
+ }
+ }
+
+ const handleReviewReject = async (logId: string, feedback: string) => {
+ try {
+ const response = await fetch(`/api/admin/review-queue/${logId}/reject`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ feedback })
+ })
+ if (!response.ok) {
+ throw new Error(`Failed to reject: ${response.statusText}`)
+ }
+ setReviewQueue(reviewQueue.filter(item => item.id !== logId))
+ // Refresh metrics to reflect the rejection
+ fetchMetrics()
+ } catch (error) {
+ console.error("Error rejecting review:", error)
+ alert("Failed to reject query. Please try again.")
+ }
+ }
+
+ const handleRetrain = async () => {
+ try {
+ await fetch("/api/admin/retrain", { method: "POST" })
+ alert("Retraining initiated. Check back in a few hours.")
+ } catch (error) {
+ console.error("Error initiating retrain:", error)
+ }
+ }
+
+ // Fetch data on mount and set up polling for real-time updates
+ useEffect(() => {
+ fetchMetrics()
+ fetchQueryLogs()
+ fetchReviewQueue()
+
+ // Auto-refresh every 30 seconds for real-time monitoring
+ const interval = setInterval(() => {
+ fetchMetrics()
+ fetchQueryLogs()
+ fetchReviewQueue()
+ }, 30000)
+
+ return () => clearInterval(interval)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ // Filter logs
+ const filteredLogs = queryLogs.filter(log => {
+ if (searchQuery && !log.query.toLowerCase().includes(searchQuery.toLowerCase())) {
+ return false
+ }
+ if (filterPersona !== "all" && log.persona !== filterPersona) {
+ return false
+ }
+ if (filterConfidence === "low" && log.confidence >= 0.6) {
+ return false
+ }
+ if (filterConfidence === "medium" && (log.confidence < 0.6 || log.confidence >= 0.8)) {
+ return false
+ }
+ if (filterConfidence === "high" && log.confidence < 0.8) {
+ return false
+ }
+ return true
+ })
+
+ return (
+
+ {/* Header */}
+
+
+
Agent Monitoring
+
Monitor and manage AmaniQ agent performance
+
+
+
+
+ Refresh
+
+
+
+ Retrain Model
+
+
+
+
+ {/* Metrics Overview */}
+ {metrics &&
}
+
+ {/* Main Tabs */}
+
+
+
+
+ Query Logs
+
+
+
+ Review Queue ({reviewQueue.length})
+
+
+
+ Analytics
+
+
+
+ Training
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+// Helper components with proper TypeScript types
+
+interface MetricsOverviewProps {
+ metrics: AgentMetrics | null
+}
+
+function MetricsOverview({ metrics }: MetricsOverviewProps) {
+ // Return placeholder if metrics not loaded
+ if (!metrics) {
+ return (
+
+ {[1, 2, 3, 4].map((i) => (
+
+
+ Loading...
+
+
+ --
+
+
+ ))}
+
+ )
+ }
+
+ return (
+
+
+
+ Total Queries
+
+
+
+ {(metrics.total_queries ?? 0).toLocaleString()}
+ Last 30 days
+
+
+
+
+
+ Avg Confidence
+
+
+
+ {((metrics.avg_confidence ?? 0) * 100).toFixed(1)}%
+
+ = 0.8 ? "default" : "secondary"} className="text-xs">
+ {(metrics.avg_confidence ?? 0) >= 0.8 ? "High" : (metrics.avg_confidence ?? 0) >= 0.6 ? "Medium" : "Low"}
+
+
+
+
+
+
+
+ Avg Response Time
+
+
+
+ {(metrics.avg_response_time_ms ?? 0).toFixed(0)}ms
+
+ {(metrics.avg_response_time_ms ?? 0) < 1000 ? "Excellent" : (metrics.avg_response_time_ms ?? 0) < 2000 ? "Good" : "Slow"}
+
+
+
+
+
+
+ Human Review Rate
+
+
+
+ {((metrics.human_review_rate ?? 0) * 100).toFixed(1)}%
+
+ {(metrics.human_review_rate ?? 0) < 0.1 ? "Low" : (metrics.human_review_rate ?? 0) < 0.3 ? "Moderate" : "High"}
+
+
+
+
+ )
+}
+
+interface QueryLogsViewProps {
+ logs: QueryLog[]
+ selectedLog: QueryLog | null
+ onSelectLog: (log: QueryLog | null) => void
+ searchQuery: string
+ onSearchChange: (query: string) => void
+ filterPersona: string
+ onFilterPersonaChange: (persona: string) => void
+ filterConfidence: string
+ onFilterConfidenceChange: (confidence: string) => void
+ loading: boolean
+}
+
+function QueryLogsView({
+ logs,
+ selectedLog,
+ onSelectLog,
+ searchQuery,
+ onSearchChange,
+ filterPersona,
+ onFilterPersonaChange,
+ filterConfidence,
+ onFilterConfidenceChange,
+ loading
+}: QueryLogsViewProps) {
+ return (
+
+
+
+ Query History
+ Recent agent queries
+
+
+
+
+ onSearchChange(e.target.value)}
+ className="pl-8"
+ />
+
+
+
+
+
+
+
+ All Personas
+ Wanjiku
+ Wakili
+ Mwanahabari
+
+
+
+
+
+
+
+
+ All Confidence
+ Low
+ Medium
+ High
+
+
+
+
+
+
+
+ {loading ? (
+
+
+
+ ) : (
+
+ {logs.map((log: QueryLog) => (
+
onSelectLog(log)}
+ className={cn(
+ "p-3 rounded-lg border cursor-pointer hover:bg-accent",
+ selectedLog?.id === log.id && "bg-accent border-primary"
+ )}
+ >
+
+
+
+ {log.persona}
+
+ {log.intent}
+
+
+ {new Date(log.timestamp).toLocaleTimeString()}
+
+
+
+
{log.query}
+
+
+
+
+
+ {(log.confidence * 100).toFixed(0)}%
+
+
+
+ {log.response_time_ms}ms
+
+
+ {log.human_review_required && (
+
+ )}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ Query Details
+
+
+ {selectedLog ? (
+
+
+ {/* Show ThinkingProcess if available */}
+ {selectedLog.reasoning_path && (
+
+ )}
+
+
+
Query
+
{selectedLog.query}
+
+
+
+
+
Persona
+
+ {selectedLog.persona}
+
+
+
+
Confidence
+
+ = 0.8 ? "default" :
+ selectedLog.confidence >= 0.6 ? "secondary" : "destructive"
+ }>
+ {(selectedLog.confidence * 100).toFixed(1)}%
+
+
+
+
+
+
+
Agent Path
+
+ {selectedLog.agent_path.map((node: string, idx: number) => (
+
+ {node}
+ {idx < selectedLog.agent_path.length - 1 && " → "}
+
+ ))}
+
+
+
+ {selectedLog.quality_issues?.length > 0 && (
+
+
Quality Issues
+
+ {selectedLog.quality_issues.map((issue: string, idx: number) => (
+
+
+ {issue}
+
+ ))}
+
+
+ )}
+
+
+ ) : (
+
+ Select a query to view details
+
+ )}
+
+
+
+ )
+}
+
+interface ReviewQueueViewProps {
+ queue: ReviewQueueItem[]
+ onApprove: (logId: string) => void
+ onReject: (logId: string, feedback: string) => void
+}
+
+function ReviewQueueView({ queue, onApprove, onReject }: ReviewQueueViewProps) {
+ const [selectedItem, setSelectedItem] = useState(null)
+ const [rejectFeedback, setRejectFeedback] = useState("")
+
+ return (
+
+
+
+ Pending Review ({queue.length})
+
+
+
+ {queue.length === 0 ? (
+
+ ) : (
+
+ {queue.map((item: ReviewQueueItem) => (
+
setSelectedItem(item)}
+ className={cn(
+ "p-3 rounded-lg border cursor-pointer hover:bg-accent",
+ selectedItem?.id === item.id && "bg-accent border-primary"
+ )}
+ >
+
+ {item.priority} priority
+
+
{item.query}
+
{item.review_reason}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ Review Details
+
+
+ {selectedItem ? (
+
+
+ Feedback
+
+
+ {
+ onApprove(selectedItem.id)
+ setSelectedItem(null)
+ }}
+ className="flex-1"
+ >
+
+ Approve
+
+ {
+ if (rejectFeedback.trim()) {
+ onReject(selectedItem.id, rejectFeedback)
+ setSelectedItem(null)
+ setRejectFeedback("")
+ }
+ }}
+ variant="destructive"
+ className="flex-1"
+ >
+
+ Reject
+
+
+
+ ) : (
+
+ Select an item to review
+
+ )}
+
+
+
+ )
+}
+
+interface AnalyticsViewProps {
+ metrics: AgentMetrics | null
+ logs?: QueryLog[]
+}
+
+function AnalyticsView({ metrics }: AnalyticsViewProps) {
+ if (!metrics) return Loading...
+
+ return (
+
+
+
+ Persona Distribution
+
+
+
+ {Object.entries(metrics.persona_distribution).map(([persona, count]: [string, number]) => {
+ const percentage = (count / metrics.total_queries) * 100
+ return (
+
+
+ {persona}
+ {count} ({percentage.toFixed(1)}%)
+
+
+
+ )
+ })}
+
+
+
+
+
+
+ Confidence Distribution
+
+
+
+ {Object.entries(metrics.confidence_buckets).map(([bucket, count]: [string, number]) => {
+ const percentage = (count / metrics.total_queries) * 100
+ return (
+
+
+ {bucket}
+ {count} ({percentage.toFixed(1)}%)
+
+
+
+ )
+ })}
+
+
+
+
+ )
+}
+
+interface TrainingViewProps {
+ onRetrain: () => void
+}
+
+function TrainingView({ onRetrain }: TrainingViewProps) {
+ const [trainingData, setTrainingData] = useState("")
+
+ return (
+
+
+ Model Retraining
+ Upload training data and initiate retraining
+
+
+
+ Training Data (JSONL)
+
+
+
+ Start Retraining
+
+
+
+ )
+}
+
+export default AgentMonitoring
diff --git a/src/components/AmaniQueryResponse.tsx b/src/components/AmaniQueryResponse.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8b68f5ec60deb612e31d4c3013782ab7826b61a6
--- /dev/null
+++ b/src/components/AmaniQueryResponse.tsx
@@ -0,0 +1,506 @@
+/**
+ * AmaniQuery v2 Response Display Component
+ *
+ * Displays structured JSON responses with persona-specific theming:
+ * - wanjiku: Warm orange/green (Kenyan flag vibes)
+ * - wakili: Navy blue + gold (legal professional)
+ * - mwanahabari: Neutral grey + data visualization
+ */
+
+import React, { useState } from 'react';
+import { ChevronDown, ChevronUp, ExternalLink, BookOpen, TrendingUp, Users } from 'lucide-react';
+import { cn } from "@/lib/utils";
+import { ThinkingProcess } from './ThinkingProcess';
+import { FactCheckAlert } from './FactCheckAlert';
+
+// ============================================================================
+// TYPES
+// ============================================================================
+
+export type QueryType = 'public_interest' | 'legal' | 'research';
+
+export interface AmaniQueryResponse {
+ query_type: QueryType;
+ language_detected: string;
+ response: {
+ summary_card: {
+ title: string;
+ content: string;
+ };
+ detailed_breakdown: {
+ points: string[];
+ };
+ kenyan_context: {
+ impact: string;
+ related_topic: string | null;
+ };
+ citations: Array<{
+ source: string;
+ url: string;
+ quote: string | null;
+ }>;
+ };
+ follow_up_suggestions: string[];
+ metadata?: {
+ reasoning_path?: {
+ query: string;
+ thoughts: Array<{
+ step: number;
+ action: string;
+ observation: string;
+ reasoning: string;
+ duration_ms?: number;
+ confidence?: number;
+ }>;
+ total_duration_ms: number;
+ final_conclusion: string;
+ };
+ quality_issues?: string[];
+ [key: string]: unknown;
+ };
+}
+
+interface AmaniQueryResponseProps {
+ data: AmaniQueryResponse;
+ className?: string;
+ onFollowUpClick?: (suggestion: string) => void;
+}
+
+// ============================================================================
+// THEME CONFIGURATION
+// ============================================================================
+
+const PERSONA_THEMES = {
+ public_interest: {
+ name: 'wanjiku',
+ classes: {
+ container: 'bg-orange-50/50 dark:bg-orange-950/20',
+ card: 'bg-white dark:bg-gray-900 border-orange-100 dark:border-orange-900/50',
+ summaryGradient: 'from-orange-600 to-green-600',
+ primaryText: 'text-orange-900 dark:text-orange-100',
+ secondaryText: 'text-orange-700 dark:text-orange-300',
+ accentBg: 'bg-orange-500',
+ accentText: 'text-orange-600 dark:text-orange-400',
+ border: 'border-orange-200 dark:border-orange-800',
+ contextBorder: 'border-green-600 dark:border-green-500',
+ iconColor: 'text-green-600 dark:text-green-500',
+ buttonHover: 'hover:bg-orange-100 dark:hover:bg-orange-900/50'
+ },
+ icon: '🇰🇪',
+ summaryTitle: 'Kwa Ufupi',
+ contextIcon: '🚌' // Matatu
+ },
+ legal: {
+ name: 'wakili',
+ classes: {
+ container: 'bg-indigo-50/50 dark:bg-indigo-950/20',
+ card: 'bg-white dark:bg-gray-900 border-indigo-100 dark:border-indigo-900/50',
+ summaryGradient: 'from-indigo-700 to-blue-900',
+ primaryText: 'text-indigo-900 dark:text-indigo-100',
+ secondaryText: 'text-indigo-700 dark:text-indigo-300',
+ accentBg: 'bg-indigo-500',
+ accentText: 'text-indigo-600 dark:text-indigo-400',
+ border: 'border-indigo-200 dark:border-indigo-800',
+ contextBorder: 'border-amber-400 dark:border-amber-500',
+ iconColor: 'text-amber-500 dark:text-amber-400',
+ buttonHover: 'hover:bg-indigo-100 dark:hover:bg-indigo-900/50'
+ },
+ icon: '⚖️',
+ summaryTitle: 'Legal Summary',
+ contextIcon: '📜'
+ },
+ research: {
+ name: 'mwanahabari',
+ classes: {
+ container: 'bg-slate-50/50 dark:bg-slate-950/20',
+ card: 'bg-white dark:bg-gray-900 border-slate-100 dark:border-slate-800',
+ summaryGradient: 'from-slate-700 to-teal-700',
+ primaryText: 'text-slate-900 dark:text-slate-100',
+ secondaryText: 'text-slate-700 dark:text-slate-300',
+ accentBg: 'bg-teal-500',
+ accentText: 'text-teal-600 dark:text-teal-400',
+ border: 'border-slate-200 dark:border-slate-700',
+ contextBorder: 'border-teal-500 dark:border-teal-400',
+ iconColor: 'text-teal-600 dark:text-teal-500',
+ buttonHover: 'hover:bg-slate-100 dark:hover:bg-slate-800'
+ },
+ icon: '📊',
+ summaryTitle: 'The Bottom Line',
+ contextIcon: '📈'
+ }
+};
+
+// ============================================================================
+// KENYAN CONTEXT ICONS
+// ============================================================================
+
+const KENYAN_CONTEXT_ICONS: Record = {
+ 'transport': '🚌', // Matatu
+ 'money': '💰',
+ 'government': '🏛️',
+ 'food': '🌽', // Maize/ugali
+ 'housing': '🏘️',
+ 'health': '🏥',
+ 'education': '📚',
+ 'default': '🇰🇪'
+};
+
+const getContextIcon = (impact: string): string => {
+ const lowercaseImpact = impact.toLowerCase();
+
+ if (lowercaseImpact.includes('transport') || lowercaseImpact.includes('matatu') || lowercaseImpact.includes('parking')) {
+ return KENYAN_CONTEXT_ICONS.transport;
+ }
+ if (lowercaseImpact.includes('money') || lowercaseImpact.includes('fee') || lowercaseImpact.includes('cost')) {
+ return KENYAN_CONTEXT_ICONS.money;
+ }
+ if (lowercaseImpact.includes('food') || lowercaseImpact.includes('mama mboga')) {
+ return KENYAN_CONTEXT_ICONS.food;
+ }
+ if (lowercaseImpact.includes('housing') || lowercaseImpact.includes('rent')) {
+ return KENYAN_CONTEXT_ICONS.housing;
+ }
+
+ return KENYAN_CONTEXT_ICONS.default;
+};
+
+// ============================================================================
+// MAIN COMPONENT
+// ============================================================================
+
+export const AmaniQueryResponse: React.FC = ({ data, className = '', onFollowUpClick }) => {
+ const [citationsExpanded, setCitationsExpanded] = useState(false);
+ const [detailsExpanded, setDetailsExpanded] = useState(true);
+
+ const theme = PERSONA_THEMES[data.query_type];
+ const contextIcon = getContextIcon(data.response.kenyan_context.impact);
+
+ return (
+
+ {/* THINKING PROCESS - Display if available */}
+ {data.metadata?.reasoning_path && (
+
+
+
+ )}
+
+ {/* FACT CHECK ALERT - Display if issues found */}
+ {data.metadata?.quality_issues && Array.isArray(data.metadata.quality_issues) && data.metadata.quality_issues.length > 0 && (
+
+ )}
+
+ {/* SUMMARY CARD - HUGE AND PROMINENT */}
+
+
+ {/* DETAILED BREAKDOWN */}
+
setDetailsExpanded(!detailsExpanded)}
+ />
+
+ {/* KENYAN CONTEXT */}
+
+
+ {/* CITATIONS */}
+ setCitationsExpanded(!citationsExpanded)}
+ />
+
+ {/* FOLLOW-UP SUGGESTIONS */}
+
+
+ );
+};
+
+// ============================================================================
+// SUB-COMPONENTS
+// ============================================================================
+
+interface ThemeProps {
+ theme: typeof PERSONA_THEMES[keyof typeof PERSONA_THEMES];
+}
+
+// SUMMARY CARD - TikTok-style prominence
+const SummaryCard: React.FC<{ title: string; content: string } & ThemeProps> = ({
+ title,
+ content,
+ theme
+}) => {
+ return (
+
+ {/* Background pattern */}
+
+ {theme.icon}
+
+
+ {/* Header */}
+
+ {theme.icon}
+
+ {theme.summaryTitle}
+
+
+
+ {/* Title - HUGE */}
+
+ {title}
+
+
+ {/* Content */}
+
+ {content}
+
+
+ );
+};
+
+// DETAILED BREAKDOWN
+const DetailedBreakdown: React.FC<{
+ points: string[];
+ isExpanded: boolean;
+ onToggle: () => void;
+} & ThemeProps> = ({ points, theme, isExpanded, onToggle }) => {
+ return (
+
+ {/* Header */}
+
+
+
+ Detailed Breakdown
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Points */}
+ {isExpanded && (
+
+ {points.map((point, idx) => (
+
+
+ {idx + 1}
+
+ {point}
+
+ ))}
+
+ )}
+
+ );
+};
+
+// KENYAN CONTEXT
+const KenyanContext: React.FC<{
+ impact: string;
+ relatedTopic: string | null;
+ icon: string;
+} & ThemeProps> = ({ impact, relatedTopic, icon, theme }) => {
+ return (
+
+ {/* Header with icon */}
+
+
+ {icon}
+
+
+
+
+ 🇰🇪 Kenyan Context
+
+
+
+ {impact}
+
+
+
+
+ {/* Related Topic */}
+ {relatedTopic && (
+
+
+
+ Related: {relatedTopic}
+
+
+ )}
+
+ );
+};
+
+// CITATIONS (Collapsible)
+const Citations: React.FC<{
+ citations: AmaniQueryResponse['response']['citations'];
+ isExpanded: boolean;
+ onToggle: () => void;
+} & ThemeProps> = ({ citations, theme, isExpanded, onToggle }) => {
+ return (
+
+ {/* Header */}
+
+
+
+ Sources ({citations.length})
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Citation List */}
+ {isExpanded && (
+
+ {citations.map((citation, idx) => (
+
+
+ {citation.source}
+
+
+ {citation.quote && (
+
+ "{citation.quote}"
+
+ )}
+
+ {citation.url !== 'N/A' && (
+
+ View source
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+};
+
+// FOLLOW-UP SUGGESTIONS
+const FollowUpSuggestions: React.FC<{
+ suggestions: string[];
+ onFollowUpClick?: (suggestion: string) => void;
+} & ThemeProps> = ({ suggestions, theme, onFollowUpClick }) => {
+ return (
+
+
+
+ Want to know more?
+
+
+
+ {suggestions.map((suggestion, idx) => (
+ onFollowUpClick?.(suggestion)}
+ className={cn(
+ "p-3 md:px-4 rounded-xl text-sm text-left transition-all duration-200 font-medium border-2",
+ "bg-transparent cursor-pointer",
+ theme.classes.border,
+ theme.classes.primaryText,
+ theme.classes.buttonHover,
+ "hover:translate-x-1 hover:shadow-md active:scale-[0.98]"
+ )}
+ >
+ {suggestion}
+
+ ))}
+
+
+ );
+};
+
+export default AmaniQueryResponse;
diff --git a/src/components/AmaniSidebar.tsx b/src/components/AmaniSidebar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2fe449e143485b405bf9830ab49f7e8841ae629c
--- /dev/null
+++ b/src/components/AmaniSidebar.tsx
@@ -0,0 +1,519 @@
+"use client"
+
+import { useState } from "react"
+import Image from "next/image"
+import Link from "next/link"
+import { usePathname, useRouter } from "next/navigation"
+import { useAuth } from "@/lib/auth-context"
+import { Button } from "./ui/button"
+import { cn } from "@/lib/utils"
+import type { ChatSession } from "./chat/types"
+import {
+ Home,
+ MessageSquare,
+ Mic,
+ User,
+ LogOut,
+ Plus,
+ Trash2,
+ Pencil,
+ Check,
+ X,
+ MoreVertical,
+ Search,
+ Clock,
+ ChevronRight
+} from "lucide-react"
+
+interface AmaniSidebarProps {
+ chatHistory: ChatSession[]
+ currentSessionId: string | null
+ onSessionSelect: (sessionId: string) => void
+ onNewSession: () => void
+ onDeleteSession: (sessionId: string) => void
+ onRenameSession: (sessionId: string, newTitle: string) => void
+ isOpen: boolean
+ onToggle: () => void
+}
+
+export function AmaniSidebar({
+ chatHistory,
+ currentSessionId,
+ onSessionSelect,
+ onNewSession,
+ onDeleteSession,
+ onRenameSession,
+ isOpen,
+ onToggle
+}: AmaniSidebarProps) {
+ const [searchQuery, setSearchQuery] = useState("")
+ const [editingSessionId, setEditingSessionId] = useState(null)
+ const [editTitle, setEditTitle] = useState("")
+ const [showUserMenu, setShowUserMenu] = useState(false)
+ const [showAllChats, setShowAllChats] = useState(false)
+ const pathname = usePathname()
+ const router = useRouter()
+ const { user, logout } = useAuth()
+
+ // Filter chat history based on search query
+ const filteredHistory = chatHistory.filter(session =>
+ (session.title || `Chat ${session.id.slice(-6)}`)
+ .toLowerCase()
+ .includes(searchQuery.toLowerCase())
+ )
+
+ // Navigation items
+ const navItems = [
+ { href: "/?noredirect=true", icon: Home, label: "Home", active: pathname === "/" },
+ { href: "/chat", icon: MessageSquare, label: "Chat", active: pathname === "/chat" },
+ { href: "/voice", icon: Mic, label: "Voice", active: pathname === "/voice" },
+ { href: "/profile", icon: User, label: "Profile", active: pathname === "/profile" },
+ ]
+
+ const handleLogout = async () => {
+ await logout()
+ router.push("/")
+ }
+
+ const startEditing = (session: ChatSession) => {
+ setEditingSessionId(session.id)
+ setEditTitle(session.title || "")
+ }
+
+ const cancelEditing = () => {
+ setEditingSessionId(null)
+ setEditTitle("")
+ }
+
+ const saveTitle = (sessionId: string) => {
+ if (editTitle.trim()) {
+ onRenameSession(sessionId, editTitle.trim())
+ }
+ setEditingSessionId(null)
+ setEditTitle("")
+ }
+
+ const formatDate = (dateString: string) => {
+ const date = new Date(dateString)
+ const now = new Date()
+ const diffTime = Math.abs(now.getTime() - date.getTime())
+ const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
+
+ if (diffDays === 1) return "Yesterday"
+ if (diffDays < 7) return `${diffDays} days ago`
+ if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`
+ return date.toLocaleDateString()
+ }
+
+ // Group sessions by date
+ const groupedSessions = filteredHistory.reduce((groups, session) => {
+ const date = new Date(session.updated_at)
+ const today = new Date()
+ const yesterday = new Date(today)
+ yesterday.setDate(yesterday.getDate() - 1)
+
+ let group = "Older"
+ if (date.toDateString() === today.toDateString()) {
+ group = "Today"
+ } else if (date.toDateString() === yesterday.toDateString()) {
+ group = "Yesterday"
+ } else if (date.getTime() > today.getTime() - 7 * 24 * 60 * 60 * 1000) {
+ group = "This Week"
+ } else if (date.getTime() > today.getTime() - 30 * 24 * 60 * 60 * 1000) {
+ group = "This Month"
+ }
+
+ if (!groups[group]) groups[group] = []
+ groups[group].push(session)
+
+ return groups
+ }, {} as Record)
+
+ // Get limited sessions for collapsed view
+ const getDisplayedSessions = () => {
+ if (showAllChats || searchQuery) return groupedSessions
+
+ // When collapsed, show all from "Today" + up to 3 total from other groups
+ const result: Record = {}
+ let remainingSlots = 3
+ const groupOrder = ["Today", "Yesterday", "This Week", "This Month", "Older"]
+
+ for (const group of groupOrder) {
+ if (groupedSessions[group]) {
+ if (group === "Today") {
+ // Always show all from today
+ result[group] = groupedSessions[group]
+ } else if (remainingSlots > 0) {
+ // Show up to remaining slots from other groups
+ result[group] = groupedSessions[group].slice(0, remainingSlots)
+ remainingSlots -= result[group].length
+ }
+ }
+ }
+
+ return result
+ }
+
+ const displayedSessions = getDisplayedSessions()
+ const totalSessions = filteredHistory.length
+ const displayedCount = Object.values(displayedSessions).reduce((sum, sessions) => sum + sessions.length, 0)
+
+ return (
+ <>
+ {/* Main Sidebar - Always visible on desktop */}
+
+ {/* Header */}
+
+
+
+
+
+
+
+
AmaniQuery
+
Democratizing Information
+
+
+
+
+
+
+
+ {/* Search */}
+
+
+ setSearchQuery(e.target.value)}
+ className="w-full pl-10 pr-3 py-2 bg-background border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
+ />
+
+
+
+ {/* Navigation */}
+
+ {navItems.map((item) => {
+ const Icon = item.icon
+ return (
+
+
+ {item.label}
+
+ )
+ })}
+
+
+ {/* Chat History */}
+
+
+
+ {chatHistory.length === 0 ? (
+
+
+
No conversations yet
+
+ Start New Chat
+
+
+ ) : (
+
+ {Object.entries(displayedSessions).map(([group, sessions]) => (
+
+
+ {group}
+
+
+ {sessions.map((session) => (
+
+
onSessionSelect(session.id)}
+ >
+
+ {editingSessionId === session.id ? (
+
+ setEditTitle(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") saveTitle(session.id)
+ if (e.key === "Escape") cancelEditing()
+ }}
+ className="flex-1 px-2 py-1 text-sm bg-background border rounded"
+ autoFocus
+ />
+ saveTitle(session.id)}
+ >
+
+
+
+
+
+
+ ) : (
+ <>
+
+ {session.title || `Chat ${session.id.slice(-6)}`}
+
+
+
+ {formatDate(session.updated_at)}
+ •
+ {session.message_count} messages
+
+ >
+ )}
+
+
+
+ {/* Action buttons - More visible for touch devices */}
+ {editingSessionId !== session.id && (
+
+
+
{
+ e.stopPropagation()
+ startEditing(session)
+ }}
+ title="Edit chat title"
+ >
+
+
+
{
+ e.stopPropagation()
+ if (confirm('Delete this conversation?')) {
+ onDeleteSession(session.id)
+ }
+ }}
+ title="Delete conversation"
+ >
+
+
+
+
+ )}
+
+ ))}
+
+
+ ))}
+
+ {/* Show All / Show Less Button */}
+ {!searchQuery && totalSessions > 3 && (
+
+ setShowAllChats(!showAllChats)}
+ >
+ {showAllChats ? (
+ <>
+
+ Show Less
+ >
+ ) : (
+ <>
+
+ Show All ({totalSessions - displayedCount} more)
+ >
+ )}
+
+
+ )}
+
+ )}
+
+
+ {/* Footer */}
+
+ {user && (
+
+
setShowUserMenu(!showUserMenu)}
+ >
+ {user.profile_image_url ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
{user.name || "User"}
+
{user.email}
+
+
+
+
+ {/* User Dropdown Menu */}
+ {showUserMenu && (
+
+ setShowUserMenu(false)}
+ >
+
+ Profile
+
+ {
+ setShowUserMenu(false)
+ handleLogout()
+ }}
+ >
+
+ Logout
+
+
+ )}
+
+ )}
+
+
+
+ Sign Out
+
+
+
+
+ {/* Mobile Sidebar Overlay */}
+
+ {isOpen && (
+
+
e.stopPropagation()}
+ >
+ {/* Mobile content would go here - simplified for mobile */}
+
+
+
AmaniQuery
+
+
+
+
+
+
+
+ {chatHistory.map((session) => (
+
{
+ onSessionSelect(session.id)
+ onToggle()
+ }}
+ >
+
+ {session.title || `Chat ${session.id.slice(-6)}`}
+
+
+ {session.message_count} messages
+
+
+ ))}
+
+
+
+ )}
+
+ >
+ )
+}
\ No newline at end of file
diff --git a/src/components/FactCheckAlert.tsx b/src/components/FactCheckAlert.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..91f06f1934ef8aa3d0d487a4846b4eaa8dc12348
--- /dev/null
+++ b/src/components/FactCheckAlert.tsx
@@ -0,0 +1,58 @@
+import React, { useState } from 'react';
+import { AlertTriangle, ChevronDown, ChevronRight, CheckCircle } from 'lucide-react';
+import { cn } from "@/lib/utils";
+
+interface FactCheckAlertProps {
+ issues: string[];
+}
+
+export const FactCheckAlert: React.FC = ({ issues }) => {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ if (!issues || issues.length === 0) return null;
+
+ return (
+
+
setIsExpanded(!isExpanded)}
+ >
+
+
+
+
+ Potential Inaccuracies Detected
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+ Our fact-checking system flagged {issues.length} potential issue{issues.length > 1 ? 's' : ''} in this response.
+
+
+
+
+ {isExpanded && (
+
+
+ {issues.map((issue, idx) => (
+
+
+ {idx + 1}.
+
+ {issue}
+
+ ))}
+
+
+
+ Please verify with official sources.
+
+
+ )}
+
+ );
+};
diff --git a/src/components/KnowledgeBaseSelector.tsx b/src/components/KnowledgeBaseSelector.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..5826bafccac26b53c78bcc1aa15edce4f1a71325
--- /dev/null
+++ b/src/components/KnowledgeBaseSelector.tsx
@@ -0,0 +1,184 @@
+"use client"
+
+/**
+ * Knowledge Base Selector - WeKnora Integration
+ *
+ * Allows users to select which knowledge bases to query
+ */
+
+import { useState } from "react"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Label } from "@/components/ui/label"
+import { Database, Scale, Newspaper, FileText, ChevronDown, Check } from "lucide-react"
+import { cn } from "@/lib/utils"
+
+interface KnowledgeBase {
+ id: string
+ name: string
+ description: string
+ icon: React.ReactNode
+ docCount?: number
+ color: string
+}
+
+// Real namespaces from vector store
+const KNOWLEDGE_BASES: KnowledgeBase[] = [
+ {
+ id: "kenya_law",
+ name: "Kenya Law",
+ description: "Constitution, Acts, Bills, Case Law",
+ icon: ,
+ color: "bg-blue-500"
+ },
+ {
+ id: "kenya_news",
+ name: "Kenya News",
+ description: "Current affairs and news articles",
+ icon: ,
+ color: "bg-green-500"
+ },
+ {
+ id: "parliament",
+ name: "Parliament",
+ description: "Bills, Hansard, Committee Reports",
+ icon: ,
+ color: "bg-purple-500"
+ },
+ {
+ id: "general",
+ name: "General",
+ description: "General knowledge base",
+ icon: ,
+ color: "bg-orange-500"
+ }
+]
+
+interface KnowledgeBaseSelectorProps {
+ selected: string[]
+ onChange: (selected: string[]) => void
+ className?: string
+}
+
+export function KnowledgeBaseSelector({
+ selected,
+ onChange,
+ className
+}: KnowledgeBaseSelectorProps) {
+ const [open, setOpen] = useState(false)
+
+ const toggleKnowledgeBase = (id: string) => {
+ if (selected.includes(id)) {
+ onChange(selected.filter(s => s !== id))
+ } else {
+ onChange([...selected, id])
+ }
+ }
+
+ const selectAll = () => {
+ onChange(KNOWLEDGE_BASES.map(kb => kb.id))
+ }
+
+ const clearAll = () => {
+ onChange([])
+ }
+
+ const selectedKBs = KNOWLEDGE_BASES.filter(kb => selected.includes(kb.id))
+
+ return (
+
+
+
+
+
+
+ {selected.length === 0
+ ? "Select sources"
+ : selected.length === KNOWLEDGE_BASES.length
+ ? "All sources"
+ : `${selected.length} sources`}
+
+
+
+
+
+
+
+
Knowledge Bases
+
+
+ All
+
+
+ Clear
+
+
+
+
+ {KNOWLEDGE_BASES.map((kb) => {
+ const isSelected = selected.includes(kb.id)
+ return (
+
toggleKnowledgeBase(kb.id)}
+ >
+
toggleKnowledgeBase(kb.id)}
+ />
+
+ {kb.icon}
+
+
+
+ {kb.name}
+ {kb.docCount && (
+
+ {(kb.docCount / 1000).toFixed(0)}k docs
+
+ )}
+
+
{kb.description}
+
+ {isSelected && }
+
+ )
+ })}
+
+ {selectedKBs.length > 0 && (
+
+
+ {selectedKBs.map(kb => (
+ toggleKnowledgeBase(kb.id)}
+ >
+ {kb.name} ×
+
+ ))}
+
+
+ )}
+
+
+ )
+}
+
+export default KnowledgeBaseSelector
diff --git a/src/components/ModelSelector.tsx b/src/components/ModelSelector.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..815effd8cddcedea00e3dbe5081741d52d46f7bd
--- /dev/null
+++ b/src/components/ModelSelector.tsx
@@ -0,0 +1,214 @@
+"use client"
+
+/**
+ * Model Selector - WeKnora Integration
+ *
+ * Allows users to switch between AI models dynamically
+ */
+
+import { useState } from "react"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Sparkles,
+ Zap,
+ Brain,
+ Rocket,
+ Info
+} from "lucide-react"
+import { cn } from "@/lib/utils"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+interface AIModel {
+ id: string
+ name: string
+ provider: string
+ description: string
+ speed: 'fast' | 'medium' | 'slow'
+ quality: 'standard' | 'high' | 'premium'
+ icon: React.ReactNode
+ available: boolean
+}
+
+// Prioritize Moonshot and Gemini (user has these APIs)
+const AI_MODELS: AIModel[] = [
+ {
+ id: "moonshot-v1-8k",
+ name: "Moonshot V1",
+ provider: "Moonshot",
+ description: "Fast, efficient for most queries",
+ speed: "fast",
+ quality: "high",
+ icon: ,
+ available: true
+ },
+ {
+ id: "gemini-2.5-flash",
+ name: "Gemini 2.5 Flash",
+ provider: "Google",
+ description: "Latest Gemini, great balance",
+ speed: "fast",
+ quality: "high",
+ icon: ,
+ available: true
+ },
+ {
+ id: "gemini-1.5-pro",
+ name: "Gemini 1.5 Pro",
+ provider: "Google",
+ description: "Best for complex analysis",
+ speed: "medium",
+ quality: "premium",
+ icon: ,
+ available: true
+ },
+ {
+ id: "gpt-4o-mini",
+ name: "GPT-4o Mini",
+ provider: "OpenAI",
+ description: "Requires OpenAI API key",
+ speed: "fast",
+ quality: "high",
+ icon: ,
+ available: false
+ },
+ {
+ id: "claude-3.5-sonnet",
+ name: "Claude 3.5 Sonnet",
+ provider: "Anthropic",
+ description: "Requires Anthropic API key",
+ speed: "medium",
+ quality: "premium",
+ icon: ,
+ available: false
+ }
+]
+
+interface ModelSelectorProps {
+ selected: string
+ onChange: (modelId: string) => void
+ className?: string
+ showDetails?: boolean
+}
+
+export function ModelSelector({
+ selected,
+ onChange,
+ className,
+ showDetails = true
+}: ModelSelectorProps) {
+ const selectedModel = AI_MODELS.find(m => m.id === selected) || AI_MODELS[0]
+
+ const getSpeedBadge = (speed: string) => {
+ switch (speed) {
+ case 'fast': return Fast
+ case 'medium': return Medium
+ case 'slow': return Slow
+ }
+ }
+
+ const getQualityBadge = (quality: string) => {
+ switch (quality) {
+ case 'premium': return Premium
+ case 'high': return High
+ case 'standard': return Standard
+ }
+ }
+
+ return (
+
+
+
+
+ {selectedModel.icon}
+
+
+
+
+ {AI_MODELS.map((model) => (
+
+
+ {model.icon}
+
+ {model.name}
+ {model.provider}
+
+
+
+ ))}
+
+
+
+ {showDetails && (
+
+
+
+
+
+
+
+
+
+
+ {selectedModel.icon}
+ {selectedModel.name}
+
+
{selectedModel.description}
+
+ {getSpeedBadge(selectedModel.speed)}
+ {getQualityBadge(selectedModel.quality)}
+
+
+
+
+
+ )}
+
+ )
+}
+
+// Compact version for sidebar
+export function ModelSelectorCompact({
+ selected,
+ onChange
+}: {
+ selected: string
+ onChange: (modelId: string) => void
+}) {
+ const selectedModel = AI_MODELS.find(m => m.id === selected) || AI_MODELS[0]
+
+ return (
+
+
+
+ {selectedModel.icon}
+ {selectedModel.name}
+
+
+
+ {AI_MODELS.map((model) => (
+
+
+ {model.icon}
+ {model.name}
+
+
+ ))}
+
+
+ )
+}
+
+export default ModelSelector
diff --git a/src/components/NotificationSubscriptionDialog.tsx b/src/components/NotificationSubscriptionDialog.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..cb040fd1080ab3c2af29bcc334f06f99c41b2e3f
--- /dev/null
+++ b/src/components/NotificationSubscriptionDialog.tsx
@@ -0,0 +1,410 @@
+"use client"
+
+import React, { useState, useEffect } from "react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { X, Bell, Clock, CheckCircle, AlertCircle } from "lucide-react"
+
+const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+
+interface NotificationSubscriptionDialogProps {
+ isOpen: boolean
+ onClose: () => void
+ phoneNumber?: string
+}
+
+interface Source {
+ name: string
+ article_count: number
+}
+
+interface Subscription {
+ id: number
+ phone_number: string
+ notification_type: "whatsapp" | "sms" | "both"
+ schedule_type: "immediate" | "daily_digest"
+ digest_time: string | null
+ categories: string[] | null
+ sources: string[] | null
+ is_active: boolean
+ created_at: string
+ updated_at: string
+}
+
+export default function NotificationSubscriptionDialog({
+ isOpen,
+ onClose,
+ phoneNumber: initialPhoneNumber = ""
+}: NotificationSubscriptionDialogProps) {
+ const [phoneNumber, setPhoneNumber] = useState(initialPhoneNumber)
+ const [notificationType, setNotificationType] = useState<"whatsapp" | "sms" | "both">("whatsapp")
+ const [scheduleType, setScheduleType] = useState<"immediate" | "daily_digest">("immediate")
+ const [digestTime, setDigestTime] = useState("08:00")
+ const [selectedCategories, setSelectedCategories] = useState([])
+ const [selectedSources, setSelectedSources] = useState([])
+ const [categories, setCategories] = useState([])
+ const [sources, setSources] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [submitting, setSubmitting] = useState(false)
+ const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null)
+ const [existingSubscription, setExistingSubscription] = useState(null)
+
+ useEffect(() => {
+ if (isOpen) {
+ fetchCategories()
+ fetchSources()
+ if (phoneNumber) {
+ checkSubscription()
+ }
+ }
+ }, [isOpen])
+
+ // Debounced check for subscription when phone number changes
+ useEffect(() => {
+ if (!isOpen || !phoneNumber || phoneNumber.length < 10) return
+
+ const timeoutId = setTimeout(() => {
+ checkSubscription()
+ }, 500)
+
+ return () => clearTimeout(timeoutId)
+ }, [phoneNumber, isOpen])
+
+ const fetchCategories = async () => {
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/v1/notifications/categories`)
+ if (response.ok) {
+ const data = await response.json()
+ setCategories(data.categories || [])
+ }
+ } catch (error) {
+ console.error("Failed to fetch categories:", error)
+ }
+ }
+
+ const fetchSources = async () => {
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/v1/notifications/sources`)
+ if (response.ok) {
+ const data = await response.json()
+ setSources(data.sources || [])
+ }
+ } catch (error) {
+ console.error("Failed to fetch sources:", error)
+ }
+ }
+
+ const checkSubscription = async () => {
+ if (!phoneNumber) return
+ setLoading(true)
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/v1/notifications/subscription/${encodeURIComponent(phoneNumber)}`)
+ if (response.ok) {
+ const data = await response.json()
+ setExistingSubscription(data)
+ setNotificationType(data.notification_type || "whatsapp")
+ setScheduleType(data.schedule_type || "immediate")
+ setDigestTime(data.digest_time || "08:00")
+ setSelectedCategories(data.categories || [])
+ setSelectedSources(data.sources || [])
+ } else if (response.status === 404) {
+ setExistingSubscription(null)
+ }
+ } catch (error) {
+ console.error("Failed to check subscription:", error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!phoneNumber.trim()) {
+ setMessage({ type: "error", text: "Please enter a phone number" })
+ return
+ }
+
+ setSubmitting(true)
+ setMessage(null)
+
+ try {
+ const payload = {
+ phone_number: phoneNumber.trim(),
+ notification_type: notificationType,
+ schedule_type: scheduleType,
+ digest_time: scheduleType === "daily_digest" ? digestTime : null,
+ categories: selectedCategories.length > 0 ? selectedCategories : null,
+ sources: selectedSources.length > 0 ? selectedSources : null,
+ }
+
+ const response = await fetch(`${API_BASE_URL}/api/v1/notifications/subscribe`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ })
+
+ if (response.ok) {
+ setMessage({ type: "success", text: "Successfully subscribed to news notifications!" })
+ setTimeout(() => {
+ onClose()
+ setMessage(null)
+ }, 2000)
+ } else {
+ const error = await response.json()
+ setMessage({ type: "error", text: error.detail || "Failed to subscribe" })
+ }
+ } catch (error) {
+ setMessage({ type: "error", text: "Failed to subscribe. Please try again." })
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ const handleUnsubscribe = async () => {
+ if (!phoneNumber.trim()) return
+
+ setSubmitting(true)
+ setMessage(null)
+
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/v1/notifications/unsubscribe?phone_number=${encodeURIComponent(phoneNumber.trim())}`, {
+ method: "POST",
+ })
+
+ if (response.ok) {
+ setMessage({ type: "success", text: "Successfully unsubscribed" })
+ setExistingSubscription(null)
+ setTimeout(() => {
+ onClose()
+ setMessage(null)
+ }, 2000)
+ } else {
+ const error = await response.json()
+ setMessage({ type: "error", text: error.detail || "Failed to unsubscribe" })
+ }
+ } catch (error) {
+ setMessage({ type: "error", text: "Failed to unsubscribe. Please try again." })
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ const toggleCategory = (category: string) => {
+ setSelectedCategories((prev) =>
+ prev.includes(category) ? prev.filter((c) => c !== category) : [...prev, category]
+ )
+ }
+
+ const toggleSource = (source: string) => {
+ setSelectedSources((prev) =>
+ prev.includes(source) ? prev.filter((s) => s !== source) : [...prev, source]
+ )
+ }
+
+ if (!isOpen) return null
+
+ return (
+
+
+
+
+
+ News Notifications
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
diff --git a/src/components/ThinkingProcess.tsx b/src/components/ThinkingProcess.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4d8c20b05d0ff084f2c4b192d8d9887e0ae4accb
--- /dev/null
+++ b/src/components/ThinkingProcess.tsx
@@ -0,0 +1,391 @@
+/**
+ * Thinking Process Component
+ *
+ * Displays the agent's reasoning path including:
+ * - Step-by-step reasoning (Claude, o1 style)
+ * - Tool execution visualization
+ * - Re-ranking progress
+ * - HyDE query expansion
+ * - Knowledge base indicators
+ */
+
+"use client"
+
+import { useState } from "react"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Progress } from "@/components/ui/progress"
+import {
+ ChevronDown,
+ ChevronRight,
+ Brain,
+ Check,
+ Clock,
+ Search,
+ Lightbulb,
+ AlertCircle,
+ TrendingUp,
+ Eye,
+ Layers,
+ Zap,
+ Database,
+ RefreshCw,
+ Filter,
+ Sparkles,
+ FileText,
+ Scale,
+ Newspaper
+} from "lucide-react"
+import { cn } from "@/lib/utils"
+
+// Enhanced Types
+interface ToolExecution {
+ name: string
+ status: 'pending' | 'running' | 'completed' | 'error'
+ input?: Record
+ output?: unknown
+ duration_ms?: number
+}
+
+interface RetrievalStep {
+ namespace: string
+ docs_retrieved: number
+ docs_after_rerank?: number
+ used_hyde?: boolean
+ query_expanded?: string
+}
+
+interface ThoughtStep {
+ step: number
+ action: string
+ observation: string
+ reasoning: string
+ duration_ms?: number
+ confidence?: number
+ tool_executions?: ToolExecution[]
+ retrieval?: RetrievalStep
+}
+
+interface ReasoningPath {
+ query: string
+ thoughts: ThoughtStep[]
+ total_duration_ms: number
+ final_conclusion: string
+ knowledge_bases_used?: string[]
+ used_reranking?: boolean
+ used_hyde?: boolean
+ model_used?: string
+}
+
+interface ThinkingProcessProps {
+ reasoning: ReasoningPath | string
+ className?: string
+ defaultExpanded?: boolean
+ showEnhancements?: boolean
+}
+
+export function ThinkingProcess({
+ reasoning,
+ className,
+ defaultExpanded = false
+}: ThinkingProcessProps) {
+ const [isExpanded, setIsExpanded] = useState(defaultExpanded)
+ const [expandedSteps, setExpandedSteps] = useState>(new Set())
+
+ // Handle string reasoning (new format)
+ if (typeof reasoning === 'string') {
+ return (
+
+ setIsExpanded(!isExpanded)}
+ >
+
+
+
+ Thinking Process
+
+
+
+ Analysis
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {isExpanded && (
+
+
+
+ {reasoning}
+
+
+
+ )}
+
+ )
+ }
+
+ // Handle structured reasoning (legacy/future format)
+ const toggleStep = (step: number) => {
+ const newExpanded = new Set(expandedSteps)
+ if (newExpanded.has(step)) {
+ newExpanded.delete(step)
+ } else {
+ newExpanded.add(step)
+ }
+ setExpandedSteps(newExpanded)
+ }
+
+ const toggleAll = () => {
+ if (isExpanded) {
+ setIsExpanded(false)
+ setExpandedSteps(new Set())
+ } else {
+ setIsExpanded(true)
+ setExpandedSteps(new Set(reasoning.thoughts.map(t => t.step)))
+ }
+ }
+
+ return (
+
+
+
+
+
+ Thinking Process
+
+
+
+ {reasoning.thoughts.length} steps
+
+
+ {reasoning.total_duration_ms}ms
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+
+ Step-by-step reasoning for: {reasoning.query}
+
+
+
+ {isExpanded && (
+
+
+
+ {/* Timeline line */}
+
+
+ {reasoning.thoughts.map((thought, idx) => (
+
toggleStep(thought.step)}
+ isLast={idx === reasoning.thoughts.length - 1}
+ />
+ ))}
+
+ {/* Final Conclusion */}
+
+
+
+
Conclusion
+
+
{reasoning.final_conclusion}
+
+
+
+
+ )}
+
+ )
+}
+
+// Individual Thought Step Card
+function ThoughtStepCard({
+ thought,
+ isExpanded,
+ onToggle,
+ isLast
+}: {
+ thought: ThoughtStep
+ isExpanded: boolean
+ onToggle: () => void
+ isLast: boolean
+}) {
+ const getActionIcon = (action: string) => {
+ const actionLower = action.toLowerCase()
+ if (actionLower.includes("search") || actionLower.includes("retriev")) {
+ return
+ }
+ if (actionLower.includes("analyz") || actionLower.includes("check")) {
+ return
+ }
+ if (actionLower.includes("reason") || actionLower.includes("think")) {
+ return
+ }
+ if (actionLower.includes("synthesiz") || actionLower.includes("combin")) {
+ return
+ }
+ return
+ }
+
+ return (
+
+ {/* Step number bubble */}
+
+ {thought.step}
+
+
+ {/* Step card */}
+
+
+ {/* Header */}
+
+
+ {getActionIcon(thought.action)}
+ {thought.action}
+
+
+ {thought.duration_ms && (
+
+
+ {thought.duration_ms}ms
+
+ )}
+ {thought.confidence !== undefined && (
+ = 0.8 ? "default" : "secondary"}
+ className="text-xs font-mono"
+ >
+ {(thought.confidence * 100).toFixed(0)}%
+
+ )}
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Expanded content */}
+ {isExpanded && (
+
+ {/* Observation */}
+
+
+
+
+ Observation
+
+
+
{thought.observation}
+
+
+ {/* Reasoning */}
+
+
+
+
+ Reasoning
+
+
+
{thought.reasoning}
+
+
+ )}
+
+
+
+ )
+}
+
+// Compact version for inline display
+export function ThinkingProcessCompact({
+ reasoning
+}: {
+ reasoning: ReasoningPath
+}) {
+ const [isExpanded, setIsExpanded] = useState(false)
+
+ return (
+
+
setIsExpanded(!isExpanded)}
+ className="flex items-center gap-2 text-sm font-medium hover:text-primary transition-colors"
+ >
+
+ View thinking process ({reasoning.thoughts.length} steps)
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+ {isExpanded && (
+
+ {reasoning.thoughts.map((thought) => (
+
+
{thought.step}. {thought.action}
+
{thought.observation}
+
+ ))}
+
+ )}
+
+ )
+}
+
+// Loading skeleton
+export function ThinkingProcessSkeleton() {
+ return (
+
+
+
+
+
+ Thinking...
+
+
+ Processing
+
+
+
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+
+
+ )
+}
+
+export default ThinkingProcess
diff --git a/src/components/ToolResultRenderer.tsx b/src/components/ToolResultRenderer.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..45608a24199186fd6f5f25dad2955d76c640f544
--- /dev/null
+++ b/src/components/ToolResultRenderer.tsx
@@ -0,0 +1,194 @@
+"use client"
+
+/**
+ * Tool Result Renderer - WeKnora Integration
+ *
+ * Renders tool execution results with rich formatting
+ */
+
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import {
+ FileText,
+ Scale,
+ Search,
+ Database,
+ CheckCircle,
+ XCircle,
+ Clock,
+ ExternalLink
+} from "lucide-react"
+import { cn } from "@/lib/utils"
+
+interface ToolResult {
+ tool_name: string
+ status: 'success' | 'error' | 'pending'
+ output: unknown
+ duration_ms?: number
+}
+
+interface ToolResultRendererProps {
+ result: ToolResult
+ className?: string
+}
+
+export function ToolResultRenderer({ result, className }: ToolResultRendererProps) {
+ const getToolIcon = (name: string) => {
+ const nameLower = name.toLowerCase()
+ if (nameLower.includes('search')) return
+ if (nameLower.includes('law') || nameLower.includes('legal')) return
+ if (nameLower.includes('document')) return
+ return
+ }
+
+ const getStatusIcon = (status: string) => {
+ switch (status) {
+ case 'success': return
+ case 'error': return
+ default: return
+ }
+ }
+
+ const renderOutput = (output: unknown) => {
+ // Array of documents
+ if (Array.isArray(output)) {
+ return (
+
+ {output.slice(0, 5).map((item, idx) => (
+
+ ))}
+ {output.length > 5 && (
+
+ +{output.length - 5} more results
+
+ )}
+
+ )
+ }
+
+ // Object with structured data
+ if (typeof output === 'object' && output !== null) {
+ const obj = output as Record
+
+ // Table data
+ if ('rows' in obj && Array.isArray(obj.rows)) {
+ return
+ }
+
+ // Generic object
+ return (
+
+ {JSON.stringify(output, null, 2)}
+
+ )
+ }
+
+ // String output
+ return {String(output)}
+ }
+
+ return (
+
+
+
+
+ {getToolIcon(result.tool_name)}
+ {result.tool_name}
+
+
+ {result.duration_ms && (
+
+ {result.duration_ms}ms
+
+ )}
+ {getStatusIcon(result.status)}
+
+
+
+
+
+ {renderOutput(result.output)}
+
+
+
+ )
+}
+
+// Document card for search results
+function DocumentCard({ document }: { document: Record }) {
+ return (
+
+
+
+
+ {String(document.title || document.source_name || 'Document')}
+
+
+ {String(document.text || document.content || document.snippet || '')}
+
+
+ {typeof document.url === 'string' && (
+
+
+
+ )}
+
+
+ {!!document.category && (
+
+ {String(document.category)}
+
+ )}
+ {!!document.score && (
+
+ {(Number(document.score) * 100).toFixed(0)}% match
+
+ )}
+
+
+ )
+}
+
+// Data table for structured results
+function DataTable({ data }: { data: Record }) {
+ const rows = data.rows as Record[]
+ const columns = data.columns as string[] || Object.keys(rows[0] || {})
+
+ return (
+
+
+
+ {columns.map(col => (
+ {col}
+ ))}
+
+
+
+ {rows.slice(0, 5).map((row, idx) => (
+
+ {columns.map(col => (
+
+ {String(row[col] || '')}
+
+ ))}
+
+ ))}
+
+
+ )
+}
+
+export default ToolResultRenderer
diff --git a/src/components/__tests__/AmaniSidebar.test.tsx b/src/components/__tests__/AmaniSidebar.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..c96655fb8c5d1ebe23cdfae623dcd573bf3c827e
--- /dev/null
+++ b/src/components/__tests__/AmaniSidebar.test.tsx
@@ -0,0 +1,387 @@
+import { render, screen, fireEvent, waitFor } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { AmaniSidebar } from "../AmaniSidebar"
+import type { ChatSession } from "../chat/types"
+
+// Mock the auth context
+jest.mock("@/lib/auth-context", () => ({
+ useAuth: () => ({
+ user: {
+ id: "test-user",
+ name: "Test User",
+ email: "test@example.com",
+ profile_image_url: null
+ },
+ logout: jest.fn()
+ })
+}))
+
+// Mock next/navigation
+jest.mock("next/navigation", () => ({
+ usePathname: () => "/chat",
+ useRouter: () => ({
+ push: jest.fn()
+ })
+}))
+
+const mockChatHistory: ChatSession[] = [
+ {
+ id: "1",
+ title: "Test Chat 1",
+ message_count: 5,
+ created_at: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(), // 2 hours ago
+ updated_at: new Date(Date.now() - 1000 * 60 * 30).toISOString() // 30 minutes ago
+ },
+ {
+ id: "2",
+ title: "Test Chat 2",
+ message_count: 12,
+ created_at: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(), // 1 day ago
+ updated_at: new Date(Date.now() - 1000 * 60 * 60 * 5).toISOString() // 5 hours ago
+ },
+ {
+ id: "3",
+ title: "Test Chat 3",
+ message_count: 8,
+ created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(), // 2 days ago
+ updated_at: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString() // 1 day ago
+ }
+]
+
+const mockHandlers = {
+ onSessionSelect: jest.fn(),
+ onNewSession: jest.fn(),
+ onDeleteSession: jest.fn(),
+ onRenameSession: jest.fn(),
+ onToggle: jest.fn()
+}
+
+describe("AmaniSidebar Component", () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it("renders sidebar with header and navigation", () => {
+ render(
+
+ )
+
+ // Check header
+ expect(screen.getByText("AmaniQuery")).toBeInTheDocument()
+ expect(screen.getByText("AI Assistant")).toBeInTheDocument()
+
+ // Check navigation
+ expect(screen.getByText("Home")).toBeInTheDocument()
+ expect(screen.getByText("Chat")).toBeInTheDocument()
+ expect(screen.getByText("Voice")).toBeInTheDocument()
+ expect(screen.getByText("Profile")).toBeInTheDocument()
+ })
+
+ it("shows search input", () => {
+ render(
+
+ )
+
+ const searchInput = screen.getByPlaceholderText("Search conversations...")
+ expect(searchInput).toBeInTheDocument()
+ })
+
+ it("renders chat history with date grouping", () => {
+ render(
+
+ )
+
+ // Should show date groups
+ expect(screen.getByText("Today")).toBeInTheDocument()
+ expect(screen.getByText("Yesterday")).toBeInTheDocument()
+
+ // Should show chat titles
+ expect(screen.getByText("Test Chat 1")).toBeInTheDocument()
+ expect(screen.getByText("Test Chat 2")).toBeInTheDocument()
+ expect(screen.getByText("Test Chat 3")).toBeInTheDocument()
+ })
+
+ it("highlights current session", () => {
+ render(
+
+ )
+
+ const currentChat = screen.getByText("Test Chat 1").closest(".group")
+ expect(currentChat).toHaveClass("bg-primary/10", "text-primary")
+ })
+
+ it("handles session selection", async () => {
+ render(
+
+ )
+
+ const chatItem = screen.getByText("Test Chat 1").closest(".cursor-pointer")
+ await userEvent.click(chatItem!)
+
+ expect(mockHandlers.onSessionSelect).toHaveBeenCalledWith("1")
+ })
+
+ it("shows action buttons on hover", async () => {
+ render(
+
+ )
+
+ const chatItem = screen.getByText("Test Chat 1").closest(".group")
+
+ // Initially, action buttons should not be visible
+ const editButton = screen.getByLabelText("Edit chat").closest("button")
+ const deleteButton = screen.getByLabelText("Delete chat").closest("button")
+
+ expect(editButton).toHaveClass("opacity-0")
+ expect(deleteButton).toHaveClass("opacity-0")
+
+ // Hover over the chat item
+ fireEvent.mouseEnter(chatItem!)
+
+ // Action buttons should become visible
+ await waitFor(() => {
+ expect(editButton).toHaveClass("opacity-100")
+ expect(deleteButton).toHaveClass("opacity-100")
+ })
+ })
+
+ it("handles editing session title", async () => {
+ render(
+
+ )
+
+ // Show edit buttons by hovering
+ const chatItem = screen.getByText("Test Chat 1").closest(".group")
+ fireEvent.mouseEnter(chatItem!)
+
+ const editButton = screen.getByLabelText("Edit chat").closest("button")!
+ await userEvent.click(editButton)
+
+ // Should show input field
+ const input = screen.getByDisplayValue("Test Chat 1")
+ expect(input).toBeInTheDocument()
+
+ // Change title
+ await userEvent.clear(input)
+ await userEvent.type(input, "Updated Chat Title")
+
+ // Save by pressing Enter
+ await userEvent.type(input, "{Enter}")
+
+ expect(mockHandlers.onRenameSession).toHaveBeenCalledWith("1", "Updated Chat Title")
+ })
+
+ it("handles deleting session", async () => {
+ render(
+
+ )
+
+ // Show delete buttons by hovering
+ const chatItem = screen.getByText("Test Chat 1").closest(".group")
+ fireEvent.mouseEnter(chatItem!)
+
+ const deleteButton = screen.getByLabelText("Delete chat").closest("button")!
+ await userEvent.click(deleteButton)
+
+ expect(mockHandlers.onDeleteSession).toHaveBeenCalledWith("1")
+ })
+
+ it("handles new chat button", async () => {
+ render(
+
+ )
+
+ const newChatButton = screen.getByRole("button", { name: /new chat/i })
+ await userEvent.click(newChatButton)
+
+ expect(mockHandlers.onNewSession).toHaveBeenCalled()
+ })
+
+ it("filters chat history based on search", async () => {
+ render(
+
+ )
+
+ const searchInput = screen.getByPlaceholderText("Search conversations...")
+ await userEvent.type(searchInput, "Chat 2")
+
+ // Should only show matching chats
+ expect(screen.getByText("Test Chat 2")).toBeInTheDocument()
+ expect(screen.queryByText("Test Chat 1")).not.toBeInTheDocument()
+ expect(screen.queryByText("Test Chat 3")).not.toBeInTheDocument()
+ })
+
+ it("shows empty state when no chat history", () => {
+ render(
+
+ )
+
+ expect(screen.getByText("No conversations yet")).toBeInTheDocument()
+ expect(screen.getByText("Start your first conversation")).toBeInTheDocument()
+ })
+
+ it("shows user profile section", () => {
+ render(
+
+ )
+
+ expect(screen.getByText("Test User")).toBeInTheDocument()
+ expect(screen.getByText("test@example.com")).toBeInTheDocument()
+ })
+
+ it("handles logout", async () => {
+ const mockLogout = jest.fn()
+ jest.mock("@/lib/auth-context", () => ({
+ useAuth: () => ({
+ user: {
+ id: "test-user",
+ name: "Test User",
+ email: "test@example.com",
+ profile_image_url: null
+ },
+ logout: mockLogout
+ })
+ }))
+
+ render(
+
+ )
+
+ const logoutButton = screen.getByRole("button", { name: /sign out/i })
+ await userEvent.click(logoutButton)
+
+ expect(mockLogout).toHaveBeenCalled()
+ })
+
+ it("handles sidebar toggle", async () => {
+ render(
+
+ )
+
+ const toggleButton = screen.getByRole("button", { name: /toggle/i })
+ await userEvent.click(toggleButton)
+
+ expect(mockHandlers.onToggle).toHaveBeenCalled()
+ })
+
+ it("shows active navigation item", () => {
+ render(
+
+ )
+
+ const chatNavItem = screen.getByText("Chat").closest("a")
+ expect(chatNavItem).toHaveClass("bg-primary/10", "text-primary")
+ })
+
+ it("formats dates correctly", () => {
+ render(
+
+ )
+
+ // Should show relative time for recent chats
+ const chatItems = screen.getAllByText(/messages/)
+ expect(chatItems.length).toBeGreaterThan(0)
+ })
+
+ it("handles mobile sidebar", () => {
+ // Mock mobile viewport
+ Object.defineProperty(window, 'innerWidth', {
+ writable: true,
+ configurable: true,
+ value: 375,
+ })
+
+ render(
+
+ )
+
+ // Should not render main sidebar on mobile when closed
+ const sidebar = screen.queryByRole("complementary")
+ expect(sidebar).not.toBeInTheDocument()
+ })
+})
\ No newline at end of file
diff --git a/src/components/admin-sidebar.tsx b/src/components/admin-sidebar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2845e1559b10520ed62a3a45571b859fd823df63
--- /dev/null
+++ b/src/components/admin-sidebar.tsx
@@ -0,0 +1,228 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import Link from "next/link"
+import { usePathname, useRouter } from "next/navigation"
+import { useAuth } from "@/lib/auth-context"
+import {
+ Home,
+ MessageSquare,
+ Mic,
+ LogOut,
+ Menu,
+ X,
+ Settings,
+ BarChart3,
+ Users,
+ Shield,
+ FileText,
+ Activity,
+ Brain,
+ ArrowBigUp,
+ Database,
+ Sparkles,
+ Zap,
+} from "lucide-react"
+import { Button } from "./ui/button"
+import { cn } from "@/lib/utils"
+
+export function AdminSidebar() {
+ const [isOpen, setIsOpen] = useState(false)
+ const [isHovered, setIsHovered] = useState(false)
+ const pathname = usePathname()
+ const router = useRouter()
+ const { user, logout } = useAuth()
+
+ // Auto-hide on desktop, show on mobile toggle
+ const [isMobile, setIsMobile] = useState(false)
+
+ useEffect(() => {
+ const checkMobile = () => {
+ setIsMobile(window.innerWidth < 768)
+ }
+ checkMobile()
+ window.addEventListener("resize", checkMobile)
+ return () => window.removeEventListener("resize", checkMobile)
+ }, [])
+
+ const handleLogout = async () => {
+ await logout()
+ }
+
+ const adminNavItems = [
+ { href: "/admin", icon: BarChart3, label: "Dashboard" },
+ { href: "/admin/users", icon: Users, label: "Users" },
+ { href: "/admin/knowledge", icon: Database, label: "Knowledge Bases" },
+ { href: "/admin/models", icon: Sparkles, label: "AI Models" },
+ { href: "/admin/rag", icon: Zap, label: "RAG Pipeline" },
+ { href: "/admin/blog", icon: FileText, label: "Blog" },
+ { href: "/admin/analytics", icon: Activity, label: "Analytics" },
+ { href: "/admin/training", icon: ArrowBigUp, label: "Training" },
+ { href: "/admin/agent-monitoring", icon: Brain, label: "Agent Monitoring" },
+ { href: "/admin/settings", icon: Settings, label: "Settings" },
+ ]
+
+ const generalNavItems = [
+ { href: "/", icon: Home, label: "Home" },
+ { href: "/chat", icon: MessageSquare, label: "Chat" },
+ { href: "/voice", icon: Mic, label: "Voice" },
+ ]
+
+ // Desktop: Show on hover at left edge
+ // Mobile: Toggle with hamburger button
+ const shouldShow = isMobile ? isOpen : isHovered
+
+ return (
+ <>
+ {/* Mobile Hamburger Button */}
+ {isMobile && (
+ setIsOpen(!isOpen)}
+ className="fixed top-4 left-4 z-50 p-2 rounded-lg bg-background border shadow-lg md:hidden"
+ aria-label="Toggle menu"
+ >
+ {isOpen ? : }
+
+ )}
+
+ {/* Mobile Overlay */}
+ {isMobile && isOpen && (
+ setIsOpen(false)}
+ />
+ )}
+
+ {/* Sidebar */}
+
!isMobile && setIsHovered(true)}
+ onMouseLeave={() => !isMobile && setIsHovered(false)}
+ >
+
+ {/* Logo/Header */}
+
+
+
+
AmaniQuery
+
+
Admin Panel
+
+
+ {/* Admin Navigation Section */}
+
+
+ Administration
+
+
+ {adminNavItems.map((item) => {
+ const Icon = item.icon
+ const isActive = pathname === item.href || (item.href !== "/admin" && pathname?.startsWith(item.href))
+ return (
+ isMobile && setIsOpen(false)}
+ className={cn(
+ "flex items-center gap-3 px-4 py-2.5 rounded-lg transition-colors text-sm",
+ isActive
+ ? "bg-primary text-primary-foreground"
+ : "hover:bg-accent"
+ )}
+ >
+
+ {item.label}
+
+ )
+ })}
+
+
+
+ {/* General Navigation Section */}
+
+
+ General
+
+
+ {generalNavItems.map((item) => {
+ const Icon = item.icon
+ const isActive = pathname === item.href
+ return (
+ isMobile && setIsOpen(false)}
+ className={cn(
+ "flex items-center gap-3 px-4 py-2.5 rounded-lg transition-colors text-sm",
+ isActive
+ ? "bg-primary text-primary-foreground"
+ : "hover:bg-accent"
+ )}
+ >
+
+ {item.label}
+
+ )
+ })}
+
+
+
+ {/* User Profile Section */}
+
+ {user && (
+
+
+ {user.profile_image_url ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
{user.name || user.email}
+
{user.email}
+
Admin
+
+
+
+ )}
+
+ {/* Logout Button */}
+
+
+ Logout
+
+
+
+
+
+ {/* Spacer for desktop (to prevent content overlap) */}
+ {!isMobile && (
+
+ )}
+ >
+ )
+}
+
diff --git a/src/components/animated-ide.tsx b/src/components/animated-ide.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..57dc091458a5f35f7f1ca09ef63da7f3d3ca4f5e
--- /dev/null
+++ b/src/components/animated-ide.tsx
@@ -0,0 +1,228 @@
+"use client"
+
+import { useState, useEffect, useRef, useMemo } from "react"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Code, Play, Square } from "lucide-react"
+
+interface AnimatedIDEProps {
+ className?: string
+}
+
+const pythonCode = `import requests
+
+# Query the AmaniQuery API
+response = requests.post(
+ "http://localhost:8000/query",
+ json={
+ "query": "What are the recent parliamentary debates?",
+ "top_k": 5,
+ "category": "Parliament"
+ }
+)
+
+# Get the response
+data = response.json()
+print(f"Answer: {data['answer']}")
+print(f"Sources: {len(data.get('sources', []))} found")`
+
+const javascriptCode = `// Using fetch API
+const response = await fetch('http://localhost:8000/query', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ query: 'What are the recent parliamentary debates?',
+ top_k: 5,
+ category: 'Parliament'
+ })
+});
+
+// Parse and display results
+const data = await response.json();
+console.log('Answer:', data.answer);
+console.log('Sources:', data.sources?.length || 0);`
+
+export function AnimatedIDE({ className }: AnimatedIDEProps) {
+ const [language, setLanguage] = useState<"python" | "javascript">("python")
+ const [isPlaying, setIsPlaying] = useState(false)
+ const [currentLineIndex, setCurrentLineIndex] = useState(0)
+ const intervalRef = useRef
(null)
+
+ const code = language === "python" ? pythonCode : javascriptCode
+ const lines = code.split("\n")
+
+ // Derive displayed code from currentLineIndex instead of storing it
+ const displayedLines = useMemo(() => {
+ if (currentLineIndex > 0 && currentLineIndex <= lines.length) {
+ return lines.slice(0, currentLineIndex).join("\n")
+ }
+ return ""
+ }, [currentLineIndex, lines])
+
+ useEffect(() => {
+ if (!isPlaying) {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current)
+ intervalRef.current = null
+ }
+ return
+ }
+
+ intervalRef.current = setInterval(() => {
+ setCurrentLineIndex((prev) => {
+ if (prev >= lines.length) {
+ setIsPlaying(false)
+ return prev
+ }
+ return prev + 1
+ })
+ }, 300) // Speed of typing each line
+
+ return () => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current)
+ intervalRef.current = null
+ }
+ }
+ }, [isPlaying, lines.length])
+
+ const handleLanguageChange = (lang: "python" | "javascript") => {
+ setLanguage(lang)
+ setIsPlaying(false)
+ setCurrentLineIndex(0)
+ }
+
+ const handlePlay = () => {
+ if (isPlaying) {
+ setIsPlaying(false)
+ } else {
+ // Reset before starting
+ setCurrentLineIndex(0)
+ setIsPlaying(true)
+ }
+ }
+
+ const handleReset = () => {
+ setIsPlaying(false)
+ setCurrentLineIndex(0)
+ }
+
+ return (
+
+
+
+
+
+ Live Code Demo
+
+
+ handleLanguageChange("python")}
+ >
+ Python
+
+ handleLanguageChange("javascript")}
+ >
+ JavaScript
+
+
+
+
+
+
+ {/* IDE Window */}
+
+ {/* Title Bar */}
+
+
+
+ {language === "python" ? "main.py" : "main.js"}
+
+
+
+ {/* Code Area */}
+
+
+
+ {displayedLines.split("\n").map((line, idx) => (
+
+
+ {idx + 1}
+
+
+ {line}
+
+
+ ))}
+ {isPlaying && currentLineIndex < lines.length && (
+
+
+ {currentLineIndex + 1}
+
+
+
+
+
+ )}
+
+
+
+
+ {/* Controls */}
+
+
+
+ {isPlaying ? (
+ <>
+
+ Stop
+ >
+ ) : (
+ <>
+
+ Play
+ >
+ )}
+
+
+ Reset
+
+
+
+ {language === "python" ? "🐍 Python" : "🟨 JavaScript"}
+
+
+
+
+
+
+ )
+}
+
diff --git a/src/components/audio-waveform.tsx b/src/components/audio-waveform.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b3460cc1ce1677f4f6b18b5dc5811e0028a3f2c2
--- /dev/null
+++ b/src/components/audio-waveform.tsx
@@ -0,0 +1,216 @@
+"use client"
+
+import { useEffect, useRef } from "react"
+import { cn } from "@/lib/utils"
+
+interface AudioWaveformProps {
+ audioStream: MediaStream | null
+ isActive: boolean
+ className?: string
+ barCount?: number
+ barColor?: string
+ activeBarColor?: string
+}
+
+// Helper function to get CSS color value from CSS custom properties
+function getColorValue(colorInput: string, fallback: string): string {
+ // If it's already a valid color (hex, rgb, hsl, named color), return it
+ if (/^#([0-9A-F]{3}){1,2}$/i.test(colorInput) ||
+ /^rgb\(|^rgba\(|^hsl\(|^hsla\(/.test(colorInput) ||
+ /^(red|blue|green|yellow|orange|purple|pink|black|white|gray|grey)$/i.test(colorInput)) {
+ return colorInput
+ }
+
+ // Get color from CSS custom properties using a test element (most reliable method)
+ if (typeof window !== "undefined" && typeof document !== "undefined") {
+ try {
+ if (colorInput.includes("primary")) {
+ const opacity = colorInput.includes("/30") ? "0.3" : "1"
+
+ // Create a test element with the Tailwind class to get computed color
+ const testEl = document.createElement("div")
+ testEl.className = "bg-primary"
+ testEl.style.position = "absolute"
+ testEl.style.visibility = "hidden"
+ testEl.style.pointerEvents = "none"
+ testEl.style.width = "1px"
+ testEl.style.height = "1px"
+ document.body.appendChild(testEl)
+
+ const computed = getComputedStyle(testEl)
+ const bgColor = computed.backgroundColor
+ document.body.removeChild(testEl)
+
+ // If we got a valid color, apply opacity if needed
+ if (bgColor && bgColor !== "rgba(0, 0, 0, 0)" && bgColor !== "transparent") {
+ // Handle RGB colors
+ if (bgColor.startsWith("rgb(")) {
+ const rgbMatch = bgColor.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/)
+ if (rgbMatch) {
+ return `rgba(${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}, ${opacity})`
+ }
+ }
+ // Handle RGBA colors - replace existing alpha
+ if (bgColor.startsWith("rgba(")) {
+ const rgbaMatch = bgColor.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*[\d.]+\)/)
+ if (rgbaMatch) {
+ return `rgba(${rgbaMatch[1]}, ${rgbaMatch[2]}, ${rgbaMatch[3]}, ${opacity})`
+ }
+ }
+ // Handle HSL colors
+ if (bgColor.startsWith("hsl(")) {
+ const hslMatch = bgColor.match(/hsl\((\d+),\s*([\d.]+)%,\s*([\d.]+)%\)/)
+ if (hslMatch) {
+ return `hsla(${hslMatch[1]}, ${hslMatch[2]}%, ${hslMatch[3]}%, ${opacity})`
+ }
+ }
+ // Handle HSLA colors - replace existing alpha
+ if (bgColor.startsWith("hsla(")) {
+ const hslaMatch = bgColor.match(/hsla\((\d+),\s*([\d.]+)%,\s*([\d.]+)%,\s*[\d.]+\)/)
+ if (hslaMatch) {
+ return `hsla(${hslaMatch[1]}, ${hslaMatch[2]}%, ${hslaMatch[3]}%, ${opacity})`
+ }
+ }
+ // For other formats, return as-is if opacity is 1, otherwise try to convert
+ if (opacity === "1") {
+ return bgColor
+ }
+ }
+ }
+ } catch (e) {
+ // Fall through to fallback
+ }
+ }
+
+ return fallback
+}
+
+export function AudioWaveform({
+ audioStream,
+ isActive,
+ className,
+ barCount = 40,
+ barColor = "bg-primary/30",
+ activeBarColor = "bg-primary",
+}: AudioWaveformProps) {
+ const canvasRef = useRef(null)
+ const animationFrameRef = useRef(null)
+ const analyserRef = useRef(null)
+ const audioContextRef = useRef(null)
+ const isAnalyzingRef = useRef(false)
+
+ useEffect(() => {
+ if (!audioStream || !canvasRef.current) {
+ isAnalyzingRef.current = false
+ return
+ }
+
+ const canvas = canvasRef.current
+ const ctx = canvas.getContext("2d")
+ if (!ctx) return
+
+ // Set canvas size
+ const rect = canvas.getBoundingClientRect()
+ canvas.width = rect.width * window.devicePixelRatio
+ canvas.height = rect.height * window.devicePixelRatio
+ ctx.scale(window.devicePixelRatio, window.devicePixelRatio)
+
+ // Create audio context and analyser
+ try {
+ const AudioContextClass = (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext) as typeof AudioContext
+ const audioContext = new AudioContextClass()
+ const analyser = audioContext.createAnalyser()
+ const source = audioContext.createMediaStreamSource(audioStream)
+
+ analyser.fftSize = 256
+ analyser.smoothingTimeConstant = 0.8
+ source.connect(analyser)
+
+ analyserRef.current = analyser
+ audioContextRef.current = audioContext
+ isAnalyzingRef.current = true
+
+ const bufferLength = analyser.frequencyBinCount
+ const dataArray = new Uint8Array(bufferLength)
+
+ // Get actual color values from CSS
+ const resolvedBarColor = getColorValue(barColor, "rgba(59, 130, 246, 0.3)") // blue-500 with 30% opacity
+ const resolvedActiveColor = getColorValue(activeBarColor, "rgb(59, 130, 246)") // blue-500
+
+ const draw = () => {
+ if (!isActive || !isAnalyzingRef.current) {
+ // Draw flat line when not active
+ ctx.clearRect(0, 0, canvas.width / window.devicePixelRatio, canvas.height / window.devicePixelRatio)
+ ctx.fillStyle = resolvedBarColor
+ const barWidth = (canvas.width / window.devicePixelRatio) / barCount
+ const barHeight = 4
+ const centerY = (canvas.height / window.devicePixelRatio) / 2
+
+ for (let i = 0; i < barCount; i++) {
+ const x = i * barWidth
+ ctx.fillRect(x, centerY - barHeight / 2, barWidth - 2, barHeight)
+ }
+ animationFrameRef.current = requestAnimationFrame(draw)
+ return
+ }
+
+ analyser.getByteFrequencyData(dataArray)
+
+ ctx.clearRect(0, 0, canvas.width / window.devicePixelRatio, canvas.height / window.devicePixelRatio)
+
+ const barWidth = (canvas.width / window.devicePixelRatio) / barCount
+ const centerY = (canvas.height / window.devicePixelRatio) / 2
+ const maxBarHeight = (canvas.height / window.devicePixelRatio) * 0.8
+
+ // Sample data points for visualization
+ const step = Math.floor(bufferLength / barCount)
+
+ for (let i = 0; i < barCount; i++) {
+ const dataIndex = i * step
+ const barHeight = (dataArray[dataIndex] / 255) * maxBarHeight
+ const x = i * barWidth
+
+ // Draw bar
+ const gradient = ctx.createLinearGradient(
+ x,
+ centerY - barHeight / 2,
+ x,
+ centerY + barHeight / 2
+ )
+ gradient.addColorStop(0, resolvedActiveColor)
+ gradient.addColorStop(1, resolvedBarColor)
+
+ ctx.fillStyle = gradient
+ ctx.fillRect(x, centerY - barHeight / 2, barWidth - 2, barHeight || 2)
+ }
+
+ animationFrameRef.current = requestAnimationFrame(draw)
+ }
+
+ draw()
+ } catch (error) {
+ console.error("Error setting up audio visualization:", error)
+ isAnalyzingRef.current = false
+ }
+
+ return () => {
+ if (animationFrameRef.current) {
+ cancelAnimationFrame(animationFrameRef.current)
+ }
+ if (audioContextRef.current) {
+ audioContextRef.current.close().catch(console.error)
+ }
+ isAnalyzingRef.current = false
+ }
+ }, [audioStream, isActive, barCount, barColor, activeBarColor])
+
+ return (
+
+
+
+ )
+}
+
diff --git a/src/components/blog-editor.tsx b/src/components/blog-editor.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e9b46390bc7fe2d87b9a1fe053679ff4163379c0
--- /dev/null
+++ b/src/components/blog-editor.tsx
@@ -0,0 +1,171 @@
+"use client"
+
+import { useState } from "react"
+import { Textarea } from "@/components/ui/textarea"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Bold, Italic, List, Link as LinkIcon, Image as ImageIcon } from "lucide-react"
+
+interface BlogEditorProps {
+ markdownContent: string
+ htmlContent: string
+ onMarkdownChange: (value: string) => void
+ onHtmlChange: (value: string) => void
+ mode?: "markdown" | "html" | "both"
+}
+
+export function BlogEditor({
+ markdownContent,
+ htmlContent,
+ onMarkdownChange,
+ onHtmlChange,
+ mode = "both",
+}: BlogEditorProps) {
+ const [activeTab, setActiveTab] = useState<"markdown" | "html">("markdown")
+
+ const insertMarkdown = (before: string, after: string = "") => {
+ const textarea = document.getElementById("markdown-editor") as HTMLTextAreaElement
+ if (!textarea) return
+
+ const start = textarea.selectionStart
+ const end = textarea.selectionEnd
+ const selectedText = markdownContent.substring(start, end)
+ const newText =
+ markdownContent.substring(0, start) +
+ before +
+ selectedText +
+ after +
+ markdownContent.substring(end)
+
+ onMarkdownChange(newText)
+
+ // Restore cursor position
+ setTimeout(() => {
+ textarea.focus()
+ const newCursorPos = start + before.length + selectedText.length + after.length
+ textarea.setSelectionRange(newCursorPos, newCursorPos)
+ }, 0)
+ }
+
+ const markdownButtons = [
+ { icon: Bold, action: () => insertMarkdown("**", "**"), label: "Bold" },
+ { icon: Italic, action: () => insertMarkdown("*", "*"), label: "Italic" },
+ { icon: List, action: () => insertMarkdown("- ", ""), label: "List" },
+ {
+ icon: LinkIcon,
+ action: () => insertMarkdown("[", "](url)"),
+ label: "Link",
+ },
+ {
+ icon: ImageIcon,
+ action: () => insertMarkdown(""),
+ label: "Image",
+ },
+ ]
+
+ if (mode === "markdown") {
+ return (
+
+
+
+
Markdown Editor
+
+ {markdownButtons.map((btn) => {
+ const Icon = btn.icon
+ return (
+
+
+
+ )
+ })}
+
+
+
+
+
+
+ )
+ }
+
+ if (mode === "html") {
+ return (
+
+
+ HTML Editor
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
Markdown Editor
+
+ {markdownButtons.map((btn) => {
+ const Icon = btn.icon
+ return (
+
+
+
+ )
+ })}
+
+
+
+
+
+
+
+
+ HTML Editor (Optional)
+
+
+
+
+
+ )
+}
+
diff --git a/src/components/blog-post-card.tsx b/src/components/blog-post-card.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..66aa638562f4c7954074459fa335ddb17fd996c3
--- /dev/null
+++ b/src/components/blog-post-card.tsx
@@ -0,0 +1,117 @@
+"use client"
+
+import Link from "next/link"
+import { Card, CardContent, CardHeader } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Calendar, User, ArrowRight } from "lucide-react"
+import Image from "next/image"
+
+interface BlogPostCardProps {
+ post: {
+ id: string
+ title: string
+ slug: string
+ excerpt?: string
+ post_type: string
+ featured_image_url?: string
+ author: {
+ id: string
+ name?: string
+ email: string
+ profile_image_url?: string
+ }
+ categories: Array<{ id: string; name: string; slug: string }>
+ tags: Array<{ id: string; name: string; slug: string }>
+ published_at?: string
+ created_at: string
+ }
+}
+
+export function BlogPostCard({ post }: BlogPostCardProps) {
+ const formatDate = (dateString: string) => {
+ const date = new Date(dateString)
+ return date.toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ })
+ }
+
+ const getPostTypeColor = (type: string) => {
+ switch (type) {
+ case "news":
+ return "bg-blue-500/10 text-blue-500"
+ case "announcement":
+ return "bg-yellow-500/10 text-yellow-500"
+ case "update":
+ return "bg-green-500/10 text-green-500"
+ default:
+ return "bg-gray-500/10 text-gray-500"
+ }
+ }
+
+ return (
+
+
+ {post.featured_image_url && (
+
+
+
+ )}
+
+
+
+ {post.post_type}
+
+ {post.published_at && (
+
+
+ {formatDate(post.published_at)}
+
+ )}
+
+
+ {post.title}
+
+
+
+ {post.excerpt && (
+
+ {post.excerpt}
+
+ )}
+
+
+
+ {post.author.name || post.author.email}
+
+
+
+ {(post.categories.length > 0 || post.tags.length > 0) && (
+
+ {post.categories.slice(0, 2).map((category) => (
+
+ {category.name}
+
+ ))}
+ {post.tags.slice(0, 3).map((tag) => (
+
+ {tag.name}
+
+ ))}
+
+ )}
+
+
+
+ )
+}
+
diff --git a/src/components/chat-stream-demo.tsx b/src/components/chat-stream-demo.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..7e1eab440b799a29eb18b6780b6fe8e6caccfd94
--- /dev/null
+++ b/src/components/chat-stream-demo.tsx
@@ -0,0 +1,208 @@
+"use client"
+
+import { useState, useEffect, useRef, useMemo } from "react"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { MessageSquare, Play, Square, RotateCcw } from "lucide-react"
+
+interface ChatStreamDemoProps {
+ className?: string
+}
+
+type ChatMode = "normal" | "hybrid" | "research"
+
+const chatModes: Record = {
+ normal: {
+ name: "Normal",
+ description: "Standard RAG query with vector search",
+ color: "bg-blue-500/10 text-blue-500 border-blue-500/20",
+ },
+ hybrid: {
+ name: "Hybrid",
+ description: "Enhanced retrieval with hybrid encoder",
+ color: "bg-purple-500/10 text-purple-500 border-purple-500/20",
+ },
+ research: {
+ name: "Research",
+ description: "Deep research with multiple sources",
+ color: "bg-green-500/10 text-green-500 border-green-500/20",
+ },
+}
+
+const demoResponses: Record = {
+ normal: "Based on the parliamentary records, the Finance Bill 2024 has been a major topic of discussion. The bill proposes several tax reforms including...",
+ hybrid: "Using enhanced hybrid retrieval, I found comprehensive information across multiple sources. The Finance Bill 2024 discussion spans several parliamentary sessions, with key debates focusing on tax reforms, housing levies, and economic impact assessments. Cross-referencing with constitutional provisions...",
+ research: "After conducting deep research across parliamentary records, constitutional documents, and related legislation, here's a comprehensive analysis: The Finance Bill 2024 represents a significant legislative proposal with implications across multiple sectors. Key findings include: 1) Tax policy changes affecting... 2) Constitutional alignment considerations... 3) Public sentiment analysis...",
+}
+
+export function ChatStreamDemo({ className }: ChatStreamDemoProps) {
+ const [mode, setMode] = useState("normal")
+ const [isStreaming, setIsStreaming] = useState(false)
+ const [currentCharIndex, setCurrentCharIndex] = useState(0)
+ const intervalRef = useRef(null)
+
+ const response = demoResponses[mode]
+
+ // Derive displayed text from currentCharIndex instead of storing it
+ const displayedText = useMemo(() => {
+ if (currentCharIndex > 0 && currentCharIndex <= response.length) {
+ return response.substring(0, currentCharIndex)
+ }
+ return ""
+ }, [currentCharIndex, response])
+
+ useEffect(() => {
+ if (!isStreaming) {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current)
+ intervalRef.current = null
+ }
+ return
+ }
+
+ intervalRef.current = setInterval(() => {
+ setCurrentCharIndex((prev) => {
+ if (prev >= response.length) {
+ setIsStreaming(false)
+ return prev
+ }
+ return prev + 1
+ })
+ }, 30) // Character-by-character streaming speed
+
+ return () => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current)
+ intervalRef.current = null
+ }
+ }
+ }, [isStreaming, response])
+
+ const handleModeChange = (newMode: ChatMode) => {
+ setMode(newMode)
+ setIsStreaming(false)
+ setCurrentCharIndex(0)
+ }
+
+ const handlePlay = () => {
+ if (isStreaming) {
+ setIsStreaming(false)
+ } else {
+ // Reset before starting
+ setCurrentCharIndex(0)
+ setIsStreaming(true)
+ }
+ }
+
+ const handleReset = () => {
+ setIsStreaming(false)
+ setCurrentCharIndex(0)
+ }
+
+ return (
+
+
+
+
+
+ Chat Stream Demo
+
+
+ {(Object.keys(chatModes) as ChatMode[]).map((m) => (
+ handleModeChange(m)}
+ className="text-xs"
+ >
+ {chatModes[m].name}
+
+ ))}
+
+
+
+
+
+ {/* Mode Info */}
+
+
+
+ {chatModes[mode].name} Mode
+
+
+
+ {chatModes[mode].description}
+
+
+
+ {/* Chat Interface */}
+
+ {/* User Message */}
+
+
+
+ What are the recent parliamentary debates on the Finance Bill?
+
+
+
+
+ {/* AI Response */}
+
+
+
+
+ {displayedText}
+ {isStreaming && (
+
+ )}
+
+
+
+
+
+ {/* Controls */}
+
+
+
= response.length}
+ >
+ {isStreaming ? (
+ <>
+
+ Stop
+ >
+ ) : (
+ <>
+
+ Start Stream
+ >
+ )}
+
+
+
+ Reset
+
+
+
+ {isStreaming ? "Streaming..." : "Ready"}
+
+
+
+
+
+ )
+}
+
diff --git a/src/components/chat.tsx b/src/components/chat.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..9e1e81c20e9d8858977f19cad6676b7642eacbd3
--- /dev/null
+++ b/src/components/chat.tsx
@@ -0,0 +1,1594 @@
+"use client"
+
+import { useState, useEffect, useCallback, useRef } from "react"
+import { toast } from "sonner"
+import { History as HistoryIcon } from "lucide-react"
+import {
+ ChatSidebar
+} from "./chat/ChatSidebar"
+import { ChatHeader } from "./chat/ChatHeader"
+import { AmaniMessageList } from "./chat/AmaniMessageList"
+import { ChatInput } from "./chat/ChatInput"
+import type {
+ Message,
+ ChatSession,
+ StreamMetadata,
+ SharePlatform,
+ ShareFormatResponse,
+ ShareSheetState
+} from "./chat/types"
+
+// Extend Window interface for OAuth callback
+declare global {
+ interface Window {
+ handleOAuthCallback?: (platform: SharePlatform, code: string, state: string) => Promise
+ }
+}
+
+export function Chat() {
+ const [messages, setMessages] = useState([])
+ const [input, setInput] = useState("")
+ const [isLoading, setIsLoading] = useState(false)
+ const [currentSessionId, setCurrentSessionId] = useState(null)
+ const [useHybrid, setUseHybrid] = useState(false)
+ const [chatHistory, setChatHistory] = useState([])
+ const [showHistory, setShowHistory] = useState(false)
+ const [showSources, setShowSources] = useState(false)
+ const [isResearchMode, setIsResearchMode] = useState(false)
+ const [researchResults, setResearchResults] = useState>({})
+ const [shareSheet, setShareSheet] = useState(null)
+ const [shareCache, setShareCache] = useState<
+ Record>>
+ >({})
+ const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([])
+ const [showAutocomplete, setShowAutocomplete] = useState(false)
+ const [selectedFiles, setSelectedFiles] = useState([])
+ const [uploadingFiles, setUploadingFiles] = useState(false)
+ const [editingMessageId, setEditingMessageId] = useState(null)
+ const [editingContent, setEditingContent] = useState("")
+ const [regeneratingMessageId, setRegeneratingMessageId] = useState(null)
+ const [isCreatingSession, setIsCreatingSession] = useState(false)
+ const [platformTokens, setPlatformTokens] = useState>({
+ twitter: null,
+ linkedin: null,
+ facebook: null,
+ whatsapp: null,
+ telegram: null,
+ email: null,
+ threads: null,
+ bluesky: null,
+ tiktok: null
+ })
+ const autocompleteTimeoutRef = useRef(null)
+ const messagesContainerRef = useRef(null)
+ const messagesEndRef = useRef(null)
+
+ const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+ const ENABLE_AUTOCOMPLETE = process.env.NEXT_PUBLIC_ENABLE_AUTOCOMPLETE !== "false"
+
+ // Helper function to get auth headers
+ const getAuthHeaders = useCallback((): Record => {
+ const token = localStorage.getItem("session_token")
+ return token ? { "X-Session-Token": token } : {}
+ }, [])
+
+ // Token management functions
+ const getStoredToken = useCallback((platform: SharePlatform): string | null => {
+ try {
+ const tokens = JSON.parse(localStorage.getItem("platform_tokens") || "{}")
+ return tokens[platform] || null
+ } catch {
+ return null
+ }
+ }, [])
+
+ const storeToken = useCallback((platform: SharePlatform, token: string) => {
+ try {
+ const tokens = JSON.parse(localStorage.getItem("platform_tokens") || "{}")
+ tokens[platform] = token
+ localStorage.setItem("platform_tokens", JSON.stringify(tokens))
+ setPlatformTokens(prev => ({ ...prev, [platform]: token }))
+ } catch (error) {
+ console.error("Failed to store token:", error)
+ }
+ }, [])
+
+ const clearToken = useCallback((platform: SharePlatform) => {
+ try {
+ const tokens = JSON.parse(localStorage.getItem("platform_tokens") || "{}")
+ delete tokens[platform]
+ localStorage.setItem("platform_tokens", JSON.stringify(tokens))
+ setPlatformTokens(prev => ({ ...prev, [platform]: null }))
+ } catch (error) {
+ console.error("Failed to clear token:", error)
+ }
+ }, [])
+
+ const loadStoredTokens = useCallback(() => {
+ try {
+ const tokens = JSON.parse(localStorage.getItem("platform_tokens") || "{}")
+ setPlatformTokens({
+ twitter: tokens.twitter || null,
+ linkedin: tokens.linkedin || null,
+ facebook: tokens.facebook || null,
+ whatsapp: null, // WhatsApp doesn't use OAuth tokens
+ telegram: null, // Telegram doesn't use OAuth tokens
+ email: null, // Email doesn't use OAuth tokens
+ threads: tokens.threads || null,
+ bluesky: tokens.bluesky || null,
+ tiktok: null // TikTok might not support this flow yet or tokens.tiktok
+ })
+ } catch (error) {
+ console.error("Failed to load stored tokens:", error)
+ }
+ }, [])
+
+ const handleAuthCallback = useCallback(async (platform: SharePlatform, code: string, state: string) => {
+ try {
+ const response = await fetch(`${API_BASE_URL}/share/auth/callback`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ platform, code, state })
+ })
+
+ if (!response.ok) {
+ throw new Error("Authentication callback failed")
+ }
+
+ const data = await response.json()
+ if (data.access_token) {
+ storeToken(platform, data.access_token)
+ toast.success(`Successfully authenticated with ${platform}`)
+ return true
+ }
+ } catch (error) {
+ console.error("Auth callback failed:", error)
+ toast.error(`Authentication failed for ${platform}`)
+ }
+ return false
+ }, [API_BASE_URL, storeToken])
+
+ // Load stored tokens on component mount
+ useEffect(() => {
+ loadStoredTokens()
+
+ // Make handleAuthCallback available globally for OAuth redirects
+ window.handleOAuthCallback = handleAuthCallback
+
+ // Check for OAuth callback parameters in URL
+ const urlParams = new URLSearchParams(window.location.search)
+ const code = urlParams.get('code')
+ const state = urlParams.get('state')
+ const platform = urlParams.get('platform')
+
+ if (code && state && platform) {
+ // Handle OAuth callback
+ handleAuthCallback(platform as SharePlatform, code, state).then(() => {
+ // Clean up URL params
+ window.history.replaceState({}, document.title, window.location.pathname)
+ })
+ }
+
+ return () => {
+ delete window.handleOAuthCallback
+ }
+ }, [loadStoredTokens, handleAuthCallback])
+
+ // OAuth flow functions
+ const initiateAuth = async (platform: SharePlatform) => {
+ try {
+ setShareSheet(prev => prev ? { ...prev, shareLinkLoading: true, shareError: null } : prev)
+
+ const response = await fetch(`${API_BASE_URL}/share/auth`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ platform })
+ })
+
+ if (!response.ok) {
+ const error = await response.json()
+ throw new Error(error.detail || "Failed to initiate authentication")
+ }
+
+ const data = await response.json()
+
+ if (data.status === "needs_auth" && data.auth_url) {
+ // Open OAuth URL in popup or new window
+ const authWindow = window.open(
+ data.auth_url,
+ `auth-${platform}`,
+ "width=600,height=700,scrollbars=yes,resizable=yes"
+ )
+
+ if (authWindow) {
+ // Listen for auth completion
+ const checkClosed = setInterval(() => {
+ if (authWindow.closed) {
+ clearInterval(checkClosed)
+ // Check if auth was successful by trying to get user info
+ checkAuthStatus(platform)
+ }
+ }, 1000)
+ }
+ } else if (data.status === "authenticated") {
+ // Already authenticated
+ if (data.user_info?.access_token) {
+ storeToken(platform, data.user_info.access_token)
+ }
+ toast.success(`Already authenticated with ${platform}`)
+ }
+
+ setShareSheet(prev => prev ? { ...prev, shareLinkLoading: false } : prev)
+ } catch (error) {
+ console.error("Failed to initiate auth:", error)
+ setShareSheet(prev => prev ? {
+ ...prev,
+ shareLinkLoading: false,
+ shareError: error instanceof Error ? error.message : "Authentication failed"
+ } : prev)
+ }
+ }
+
+ const checkAuthStatus = async (platform: SharePlatform) => {
+ try {
+ // Try to get user info to check if authenticated
+ const response = await fetch(`${API_BASE_URL}/share/auth/status`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ platform })
+ })
+
+ if (response.ok) {
+ const data = await response.json()
+ if (data.status === "authenticated" && data.user_info?.access_token) {
+ storeToken(platform, data.user_info.access_token)
+ toast.success(`Successfully authenticated with ${platform}`)
+ return true
+ } else {
+ // Clear token if not authenticated
+ clearToken(platform)
+ }
+ } else {
+ clearToken(platform)
+ }
+ } catch (error) {
+ console.error("Failed to check auth status:", error)
+ clearToken(platform)
+ }
+ return false
+ }
+
+ const scrollMessagesToBottom = useCallback(
+ (behavior: ScrollBehavior = "smooth") => {
+ requestAnimationFrame(() => {
+ if (messagesContainerRef.current) {
+ messagesContainerRef.current.scrollTo({
+ top: messagesContainerRef.current.scrollHeight,
+ behavior
+ })
+ }
+ messagesEndRef.current?.scrollIntoView({ behavior, block: "end" })
+ })
+ },
+ []
+ )
+
+ useEffect(() => {
+ scrollMessagesToBottom(messages.length < 3 ? "instant" : "smooth")
+ }, [messages, isLoading, scrollMessagesToBottom])
+
+ const findPreviousUserPrompt = useCallback(
+ (messageId: string) => {
+ const index = messages.findIndex((msg) => msg.id === messageId)
+ if (index === -1) return undefined
+ for (let i = index - 1; i >= 0; i--) {
+ if (messages[i].role === "user") {
+ return messages[i].content
+ }
+ }
+ return undefined
+ },
+ [messages]
+ )
+
+ const loadChatHistory = useCallback(async () => {
+ try {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ ...getAuthHeaders()
+ }
+ // Use cache API route instead of direct backend call
+ const response = await fetch(`/api/cache/sessions`, {
+ headers
+ })
+ if (response.ok) {
+ const sessions = await response.json()
+ setChatHistory(sessions)
+ // Log cache hit/miss for debugging
+ const cacheStatus = response.headers.get("X-Cache")
+ if (cacheStatus) {
+ console.log(`Sessions cache: ${cacheStatus}`)
+ }
+ }
+ } catch (error) {
+ console.error("Failed to load chat history:", error)
+ }
+ }, [getAuthHeaders])
+
+ const createNewSession = useCallback(async (firstMessage?: string) => {
+ let title = "New Chat"
+ if (firstMessage) {
+ const content = firstMessage.trim()
+ if (content.length <= 50) {
+ title = content
+ } else {
+ title = content.substring(0, 50).split(' ').slice(0, -1).join(' ') + "..."
+ }
+ }
+
+ setIsCreatingSession(true)
+ try {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ ...getAuthHeaders()
+ }
+ const response = await fetch(`${API_BASE_URL}/api/v1/chat/sessions`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ title })
+ })
+ if (response.ok) {
+ const session = await response.json()
+ setCurrentSessionId(session.id)
+ setMessages([])
+ // Invalidate sessions cache since we created a new one
+ try {
+ const invalidateHeaders: Record = {
+ ...getAuthHeaders()
+ }
+ await fetch(`/api/cache/sessions`, {
+ method: "DELETE",
+ headers: invalidateHeaders
+ })
+ } catch (err) {
+ console.error("Failed to invalidate sessions cache:", err)
+ }
+ loadChatHistory()
+ return session.id
+ }
+ } catch (error) {
+ console.error("Failed to create session:", error)
+ } finally {
+ setIsCreatingSession(false)
+ }
+ return null
+ }, [getAuthHeaders, API_BASE_URL, loadChatHistory])
+
+ // Load chat history on component mount
+ useEffect(() => {
+ loadChatHistory()
+ }, [loadChatHistory])
+
+ // Autocomplete functionality
+ const fetchAutocomplete = useCallback(async (query: string) => {
+ if (!ENABLE_AUTOCOMPLETE || !query.trim() || query.length < 2) {
+ setAutocompleteSuggestions([])
+ setShowAutocomplete(false)
+ return
+ }
+
+ try {
+ const response = await fetch(
+ `${API_BASE_URL}/api/autocomplete?q=${encodeURIComponent(query)}&max_results=5&location=Kenya&language=en`
+ )
+ if (response.ok) {
+ const data = await response.json()
+ const suggestions = data.suggestions?.map((s: { text: string }) => s.text) || []
+ setAutocompleteSuggestions(suggestions)
+ setShowAutocomplete(suggestions.length > 0)
+ }
+ } catch (error) {
+ console.error("Failed to fetch autocomplete:", error)
+ setAutocompleteSuggestions([])
+ setShowAutocomplete(false)
+ }
+ }, [API_BASE_URL, ENABLE_AUTOCOMPLETE])
+
+ // Debounced autocomplete
+ useEffect(() => {
+ if (!ENABLE_AUTOCOMPLETE) {
+ setAutocompleteSuggestions([])
+ setShowAutocomplete(false)
+ return
+ }
+
+ if (autocompleteTimeoutRef.current) {
+ clearTimeout(autocompleteTimeoutRef.current)
+ }
+
+ if (input.trim().length >= 2) {
+ autocompleteTimeoutRef.current = setTimeout(() => {
+ fetchAutocomplete(input)
+ }, 300)
+ } else {
+ setAutocompleteSuggestions([])
+ setShowAutocomplete(false)
+ }
+
+ return () => {
+ if (autocompleteTimeoutRef.current) {
+ clearTimeout(autocompleteTimeoutRef.current)
+ }
+ }
+ }, [input, fetchAutocomplete, ENABLE_AUTOCOMPLETE])
+
+ const loadSession = async (sessionId: string) => {
+ try {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ ...getAuthHeaders()
+ }
+ // Use cache API route instead of direct backend call
+ const response = await fetch(`/api/cache/sessions/${sessionId}/messages`, {
+ headers
+ })
+ if (response.ok) {
+ const sessionMessages = await response.json()
+ setMessages(sessionMessages)
+ setCurrentSessionId(sessionId)
+ setShowHistory(false)
+ // Log cache hit/miss for debugging
+ const cacheStatus = response.headers.get("X-Cache")
+ if (cacheStatus) {
+ console.log(`Session ${sessionId} messages cache: ${cacheStatus}`)
+ }
+ }
+ } catch (error) {
+ console.error("Failed to load session:", error)
+ }
+ }
+
+ const uploadFiles = useCallback(async (sessionId: string, files: File[]): Promise => {
+ const attachmentIds: string[] = []
+ setUploadingFiles(true)
+
+ try {
+ for (const file of files) {
+ const formData = new FormData()
+ formData.append("file", file)
+
+ const headers: Record = {
+ ...getAuthHeaders()
+ }
+ const response = await fetch(`${API_BASE_URL}/api/v1/chat/sessions/${sessionId}/attachments`, {
+ method: "POST",
+ headers,
+ body: formData,
+ })
+
+ if (response.ok) {
+ const data = await response.json()
+ attachmentIds.push(data.attachment.id)
+ } else {
+ const error = await response.json()
+ throw new Error(error.detail || "Failed to upload file")
+ }
+ }
+ } finally {
+ setUploadingFiles(false)
+ }
+
+ return attachmentIds
+ }, [getAuthHeaders, API_BASE_URL])
+
+ const sendMessage = useCallback(async (content: string) => {
+ if (!content.trim() && selectedFiles.length === 0) return
+ if (isLoading || isCreatingSession || uploadingFiles) return
+
+ setIsLoading(true)
+ try {
+ let sessionId = currentSessionId
+ if (!sessionId) {
+ sessionId = await createNewSession(content.trim() || "File upload")
+ if (!sessionId) {
+ setIsLoading(false)
+ return
+ }
+ }
+
+ // Upload files first if any
+ let attachmentIds: string[] = []
+ let attachmentMetadata: Message["attachments"] = []
+ if (selectedFiles.length > 0) {
+ try {
+ attachmentIds = await uploadFiles(sessionId, selectedFiles)
+ // Fetch attachment details to display immediately
+ try {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ ...getAuthHeaders()
+ }
+ const attachmentPromises = attachmentIds.map(id =>
+ fetch(`${API_BASE_URL}/api/v1/chat/sessions/${sessionId}/attachments/${id}`, {
+ headers
+ })
+ .then(res => res.ok ? res.json() : null)
+ .catch(() => null)
+ )
+ const attachmentResults = await Promise.all(attachmentPromises)
+ attachmentMetadata = attachmentResults
+ .filter(att => att !== null)
+ .map(att => ({
+ id: att.id,
+ filename: att.filename,
+ file_type: att.file_type,
+ file_size: att.file_size,
+ uploaded_at: att.uploaded_at,
+ processed: att.processed || false,
+ cloudinary_url: att.cloudinary_url
+ }))
+ } catch (error) {
+ console.error("Failed to fetch attachment metadata:", error)
+ }
+ setSelectedFiles([]) // Clear selected files after upload
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Failed to upload files")
+ setIsLoading(false)
+ return
+ }
+ }
+
+ const userMessage: Message = {
+ id: Date.now().toString(),
+ session_id: sessionId,
+ role: "user",
+ content: content.trim() || `Uploaded ${selectedFiles.length} file(s)`,
+ created_at: new Date().toISOString(),
+ saved: false,
+ attachments: attachmentMetadata.length > 0 ? attachmentMetadata : undefined
+ }
+
+ setMessages(prev => [...prev, userMessage])
+ setInput("")
+ setIsLoading(true)
+
+ try {
+ let response;
+ if (isResearchMode) {
+ const headers: Record = {
+ "Content-Type": "application/x-www-form-urlencoded",
+ ...getAuthHeaders()
+ }
+ response = await fetch(`${API_BASE_URL}/research/analyze-legal-query`, {
+ method: "POST",
+ headers,
+ body: new URLSearchParams({
+ query: content.trim()
+ })
+ })
+ } else if (useHybrid) {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ ...getAuthHeaders()
+ }
+ response = await fetch(`${API_BASE_URL}/stream/query`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ query: content.trim(),
+ top_k: 5,
+ include_sources: true,
+ stream: true
+ })
+ })
+ } else {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ ...getAuthHeaders()
+ }
+ response = await fetch(`${API_BASE_URL}/api/v1/chat/sessions/${sessionId}/messages`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ content: content.trim(),
+ role: "user",
+ stream: true,
+ attachment_ids: attachmentIds.length > 0 ? attachmentIds : undefined
+ })
+ })
+ }
+
+ if (response.ok) {
+ if (isResearchMode) {
+ const data = await response.json()
+ const analysis = data.analysis || {}
+
+ const queryInterpretation = analysis.query_interpretation || 'Analyzing your legal question...'
+ const applicableLaws = Array.isArray(analysis.applicable_laws)
+ ? analysis.applicable_laws.join('\n- ')
+ : (analysis.applicable_laws || 'Researching relevant Kenyan laws...')
+ const legalAnalysis = analysis.legal_analysis || 'Conducting detailed legal analysis...'
+ const practicalGuidance = analysis.practical_guidance || {}
+ const guidanceSteps = Array.isArray(practicalGuidance.steps)
+ ? practicalGuidance.steps
+ : (typeof practicalGuidance === 'string' ? [practicalGuidance] : ['Review the analysis above'])
+ const guidanceSummary = practicalGuidance.summary || ''
+ const additionalConsiderations = Array.isArray(analysis.additional_considerations)
+ ? analysis.additional_considerations
+ : (typeof analysis.additional_considerations === 'string'
+ ? [analysis.additional_considerations]
+ : ['Review all applicable laws'])
+ const researchProcess = analysis.research_process || {}
+
+ const researchContent = `
+## 📋 Legal Research Analysis
+
+**Your Question:** ${data.original_query || content.trim()}
+
+---
+
+### 🔍 Query Understanding
+
+${queryInterpretation}
+
+---
+
+### 📖 Applicable Laws & Provisions
+
+${Array.isArray(analysis.applicable_laws)
+ ? analysis.applicable_laws.map((law: string, idx: number) => `${idx + 1}. ${law}`).join('\n')
+ : `- ${applicableLaws}`}
+
+---
+
+### ⚖️ Legal Analysis
+
+${legalAnalysis}
+
+---
+
+### 🛠️ Practical Guidance
+
+${guidanceSummary ? `**Summary:** ${guidanceSummary}\n\n` : ''}**Action Steps:**
+
+${guidanceSteps.map((step: string, idx: number) => `${idx + 1}. ${step}`).join('\n')}
+
+---
+
+### ⚠️ Important Considerations
+
+${additionalConsiderations.map((consideration: string) => `- ${consideration}`).join('\n')}
+
+---
+
+${researchProcess.steps_completed || researchProcess.sources_consulted ? `### 📊 Research Process
+
+- **Steps Completed:** ${researchProcess.steps_completed || 0}
+- **Actions Taken:** ${researchProcess.actions_taken || 0}
+- **Sources Consulted:** ${researchProcess.sources_consulted || 0}
+${researchProcess.tools_used && researchProcess.tools_used.length > 0
+ ? `- **Tools Used:** ${researchProcess.tools_used.join(', ')}`
+ : ''}
+
+---\n` : ''}### 📝 Disclaimer
+
+*This analysis is for informational purposes only and does not constitute legal advice. Please consult with a qualified legal professional for advice specific to your situation.*
+ `.trim()
+
+ const assistantMessage: Message = {
+ id: Date.now().toString(),
+ session_id: sessionId,
+ role: "assistant",
+ content: researchContent,
+ created_at: new Date().toISOString(),
+ model_used: data.model_used || "gemini-research",
+ saved: true
+ }
+ setMessages(prev => [...prev, assistantMessage])
+
+ setResearchResults(prev => ({
+ ...prev,
+ [assistantMessage.id]: data
+ }))
+ } else {
+ const assistantMessageId = Date.now().toString()
+ const assistantMessage: Message = {
+ id: assistantMessageId,
+ session_id: sessionId,
+ role: "assistant",
+ content: "",
+ created_at: new Date().toISOString(),
+ model_used: "streaming",
+ saved: false
+ }
+
+ setMessages(prev => [...prev, assistantMessage])
+
+ const reader = response.body?.getReader()
+ const decoder = new TextDecoder()
+ let accumulatedContent = ""
+ const metadata: StreamMetadata = {}
+
+ if (reader) {
+ try {
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+
+ const chunk = decoder.decode(value, { stream: true })
+ const lines = chunk.split('\n')
+
+ for (const line of lines) {
+ if (line.startsWith('data: ')) {
+ const data = line.slice(6)
+ if (data === '[DONE]') continue
+
+ if (data.startsWith('[DONE]')) {
+ try {
+ const parsed = JSON.parse(data.slice(7))
+ metadata.sources = parsed.sources || []
+ metadata.token_count = parsed.retrieved_chunks
+ metadata.model_used = parsed.model_used || (parsed.hybrid_used ? 'hybrid' : 'standard')
+ setMessages(prev => prev.map(msg =>
+ msg.id === assistantMessageId
+ ? {
+ ...msg,
+ content: accumulatedContent,
+ token_count: metadata.token_count,
+ model_used: metadata.model_used,
+ sources: metadata.sources,
+ saved: true
+ }
+ : msg
+ ))
+ break
+ } catch {
+ // Fall through
+ }
+ }
+
+ if (useHybrid && !data.startsWith('{') && !data.startsWith('[')) {
+ accumulatedContent += data
+ setMessages(prev => prev.map(msg =>
+ msg.id === assistantMessageId
+ ? { ...msg, content: accumulatedContent }
+ : msg
+ ))
+ continue
+ }
+
+ try {
+ const parsed = JSON.parse(data)
+
+ if (parsed.type === 'sources') {
+ metadata.sources = parsed.sources
+ metadata.token_count = parsed.retrieved_chunks
+ metadata.model_used = parsed.model_used
+ } else if (parsed.type === 'content') {
+ accumulatedContent += parsed.content
+ setMessages(prev => prev.map(msg =>
+ msg.id === assistantMessageId
+ ? { ...msg, content: accumulatedContent }
+ : msg
+ ))
+ } else if (parsed.type === 'done') {
+ setMessages(prev => prev.map(msg =>
+ msg.id === assistantMessageId
+ ? {
+ ...msg,
+ content: parsed.full_answer || accumulatedContent,
+ token_count: metadata.token_count,
+ model_used: metadata.model_used,
+ sources: metadata.sources,
+ structured_response: parsed.structured_data,
+ saved: true
+ }
+ : msg
+ ))
+ break
+ } else if (parsed.type === 'error') {
+ console.error('Streaming error:', parsed.error)
+
+ setMessages(prev => prev.map(msg =>
+ msg.id === userMessage.id
+ ? { ...msg, failed: true, originalQuery: content.trim() }
+ : msg
+ ))
+
+ setMessages(prev => prev.map(msg =>
+ msg.id === assistantMessageId
+ ? {
+ ...msg,
+ content: `Error: ${parsed.error}`,
+ model_used: 'error',
+ saved: true,
+ failed: true,
+ originalQuery: content.trim()
+ }
+ : msg
+ ))
+ break
+ }
+ } catch {
+ continue
+ }
+ }
+ }
+ }
+ } finally {
+ reader.releaseLock()
+ }
+ }
+ }
+ // Invalidate session messages cache since new message was added
+ try {
+ const invalidateHeaders: Record = {
+ ...getAuthHeaders()
+ }
+ await Promise.all([
+ fetch(`/api/cache/sessions/${sessionId}/messages`, {
+ method: "DELETE",
+ headers: invalidateHeaders
+ }),
+ // Also invalidate sessions list to capture any auto-generated titles
+ fetch(`/api/cache/sessions`, {
+ method: "DELETE",
+ headers: invalidateHeaders
+ })
+ ])
+ } catch (err) {
+ console.error("Failed to invalidate cache:", err)
+ }
+ loadChatHistory()
+ } else {
+ setMessages(prev => prev.map(msg =>
+ msg.id === userMessage.id
+ ? { ...msg, failed: true, originalQuery: content.trim() }
+ : msg
+ ))
+
+ const errorMessage: Message = {
+ id: (Date.now() + 1).toString(),
+ session_id: sessionId,
+ role: "assistant",
+ content: isResearchMode
+ ? "Sorry, I encountered an error processing your legal research request."
+ : "Sorry, I encountered an error processing your request.",
+ created_at: new Date().toISOString(),
+ failed: true,
+ originalQuery: content.trim()
+ }
+ setMessages(prev => [...prev, errorMessage])
+ }
+ } catch (error) {
+ console.error("Failed to send message:", error)
+
+ setMessages(prev => prev.map(msg =>
+ msg.id === userMessage.id
+ ? { ...msg, failed: true, originalQuery: content.trim() }
+ : msg
+ ))
+
+ const errorMessage: Message = {
+ id: (Date.now() + 1).toString(),
+ session_id: sessionId,
+ role: "assistant",
+ content: isResearchMode
+ ? "Sorry, I couldn't connect to the research service. Please try again."
+ : "Sorry, I couldn't connect to the server. Please try again.",
+ created_at: new Date().toISOString(),
+ failed: true,
+ originalQuery: content.trim()
+ }
+ setMessages(prev => [...prev, errorMessage])
+ } finally {
+ setIsLoading(false)
+ }
+ } catch (error) {
+ console.error("Failed to process message:", error)
+ setIsLoading(false)
+ }
+ }, [currentSessionId, selectedFiles, isResearchMode, useHybrid, getAuthHeaders, API_BASE_URL, loadChatHistory, createNewSession, uploadFiles, isLoading, isCreatingSession, uploadingFiles])
+
+ const submitFeedback = async (messageId: string, feedbackType: "like" | "dislike") => {
+ try {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ ...getAuthHeaders()
+ }
+ const response = await fetch(`${API_BASE_URL}/api/v1/chat/feedback`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ message_id: messageId,
+ feedback_type: feedbackType === "like" ? "positive" : "negative"
+ })
+ })
+
+ if (response.ok) {
+ setMessages(prev => prev.map(msg =>
+ msg.id === messageId ? { ...msg, feedback_type: feedbackType } : msg
+ ))
+ }
+ } catch (error) {
+ console.error("Failed to submit feedback:", error)
+ }
+ }
+
+ const copyToClipboard = async (text: string) => {
+ try {
+ await navigator.clipboard.writeText(text)
+ toast.success("Message copied to clipboard!")
+ } catch (error) {
+ console.error("Failed to copy to clipboard:", error)
+ toast.error("Failed to copy to clipboard")
+ }
+ }
+
+ const copyFailedQuery = async (message: Message) => {
+ const query = message.originalQuery || message.content
+ try {
+ await navigator.clipboard.writeText(query)
+ toast.success("Query copied to clipboard!")
+ } catch (error) {
+ console.error("Failed to copy query:", error)
+ toast.error("Failed to copy query")
+ }
+ }
+
+ const editFailedQuery = (message: Message) => {
+ const query = message.originalQuery || message.content
+ setInput(query)
+ setTimeout(() => {
+ const inputElement = document.querySelector('textarea') as HTMLTextAreaElement
+ inputElement?.focus()
+ }, 100)
+ }
+
+ const resendFailedQuery = async (message: Message) => {
+ const query = message.originalQuery || (message.role === "user" ? message.content : "")
+ if (!query.trim()) return
+
+ setMessages(prev => prev.filter(msg =>
+ msg.id !== message.id &&
+ !(msg.failed && (msg.originalQuery === query || (msg.role === "user" && msg.content === query)))
+ ))
+
+ await sendMessage(query)
+ }
+
+ const startEditingMessage = (message: Message) => {
+ if (message.role !== "user") return
+ setEditingMessageId(message.id)
+ setEditingContent(message.content)
+ }
+
+ const cancelEditing = () => {
+ setEditingMessageId(null)
+ setEditingContent("")
+ }
+
+ const saveEditedMessage = async (messageId: string) => {
+ if (!editingContent.trim()) return
+
+ const messageIndex = messages.findIndex(m => m.id === messageId)
+ if (messageIndex === -1) return
+
+ setMessages(prev => prev.slice(0, messageIndex))
+
+ setEditingMessageId(null)
+ setEditingContent("")
+ await sendMessage(editingContent)
+ }
+
+ const regenerateMessage = async (messageId: string) => {
+ const messageIndex = messages.findIndex(m => m.id === messageId)
+ if (messageIndex === -1 || messages[messageIndex].role !== "assistant") return
+
+ let userMessageIndex = -1
+ let userMessageContent = ""
+ for (let i = messageIndex - 1; i >= 0; i--) {
+ if (messages[i].role === "user") {
+ userMessageIndex = i
+ userMessageContent = messages[i].content
+ break
+ }
+ }
+
+ if (userMessageIndex === -1) return
+
+ setMessages(prev => prev.slice(0, userMessageIndex + 1))
+
+ setRegeneratingMessageId(messageId)
+ await sendMessage(userMessageContent)
+ setRegeneratingMessageId(null)
+ }
+
+ const deleteSession = async (sessionId: string) => {
+ if (!confirm("Are you sure you want to delete this chat session? This action cannot be undone.")) {
+ return
+ }
+
+ try {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ ...getAuthHeaders()
+ }
+ const response = await fetch(`${API_BASE_URL}/chat/sessions/${sessionId}`, {
+ method: "DELETE",
+ headers
+ })
+
+ if (response.ok) {
+ setChatHistory(prev => prev.filter(session => session.id !== sessionId))
+ if (currentSessionId === sessionId) {
+ setCurrentSessionId(null)
+ setMessages([])
+ }
+ // Invalidate both sessions list and specific session messages cache
+ try {
+ const invalidateHeaders: Record = {
+ ...getAuthHeaders()
+ }
+ await Promise.all([
+ fetch(`/api/cache/sessions`, {
+ method: "DELETE",
+ headers: invalidateHeaders
+ }),
+ fetch(`/api/cache/sessions/${sessionId}/messages`, {
+ method: "DELETE",
+ headers: invalidateHeaders
+ })
+ ])
+ } catch (err) {
+ console.error("Failed to invalidate cache:", err)
+ }
+ }
+ } catch (error) {
+ console.error("Failed to delete session:", error)
+ }
+ }
+
+ const renameSession = async (sessionId: string, newTitle: string) => {
+ try {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ ...getAuthHeaders()
+ }
+ const response = await fetch(`${API_BASE_URL}/chat/sessions/${sessionId}`, {
+ method: "PATCH",
+ headers,
+ body: JSON.stringify({ title: newTitle })
+ })
+
+ if (response.ok) {
+ // Update local state
+ setChatHistory(prev => prev.map(s =>
+ s.id === sessionId ? { ...s, title: newTitle } : s
+ ))
+
+ // Invalidate sessions cache
+ try {
+ const invalidateHeaders: Record = {
+ ...getAuthHeaders()
+ }
+ await fetch(`/api/cache/sessions`, {
+ method: "DELETE",
+ headers: invalidateHeaders
+ })
+ } catch (err) {
+ console.error("Failed to invalidate sessions cache:", err)
+ }
+
+ toast.success("Chat renamed")
+ }
+ } catch (error) {
+ console.error("Failed to rename session:", error)
+ toast.error("Failed to rename chat")
+ }
+ }
+
+ const shareChat = async (options: { silent?: boolean } = {}) => {
+ if (!currentSessionId) return null
+
+ try {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ ...getAuthHeaders()
+ }
+ const response = await fetch(
+ `${API_BASE_URL}/api/v1/chat/share?session_id=${encodeURIComponent(currentSessionId)}`,
+ {
+ method: "POST",
+ headers
+ }
+ )
+ if (response.ok) {
+ const data = await response.json()
+ const shareUrl = `${window.location.origin}${data.share_link}`
+ if (!options.silent) {
+ await navigator.clipboard.writeText(shareUrl)
+ toast.success("Share link copied to clipboard!")
+ }
+ return shareUrl
+ } else {
+ if (!options.silent) {
+ toast.error("Failed to generate share link")
+ }
+ }
+ } catch (error) {
+ console.error("Failed to generate share link:", error)
+ if (!options.silent) {
+ toast.error("Failed to generate share link")
+ }
+ }
+ return null
+ }
+
+ const generatePDF = async (messageId: string) => {
+ const researchData = researchResults[messageId]
+ if (!researchData) {
+ toast.error("Research data not found")
+ return
+ }
+
+ try {
+ const response = await fetch(`${API_BASE_URL}/research/generate-pdf-report`, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ analysis_results: JSON.stringify(researchData),
+ report_title: "Legal Research Report"
+ })
+ })
+
+ if (response.ok) {
+ const blob = await response.blob()
+ const url = window.URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = 'legal_research_report.pdf'
+ document.body.appendChild(a)
+ a.click()
+ window.URL.revokeObjectURL(url)
+ document.body.removeChild(a)
+ toast.success("PDF report downloaded!")
+ } else {
+ toast.error("Failed to generate PDF report")
+ }
+ } catch (error) {
+ console.error("Error generating PDF:", error)
+ toast.error("Error generating PDF report")
+ }
+ }
+
+ const generateWord = async (messageId: string) => {
+ const researchData = researchResults[messageId]
+ if (!researchData) {
+ toast.error("Research data not found")
+ return
+ }
+
+ try {
+ const response = await fetch(`${API_BASE_URL}/research/generate-word-report`, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ analysis_results: JSON.stringify(researchData),
+ report_title: "Legal Research Report"
+ })
+ })
+
+ if (response.ok) {
+ const blob = await response.blob()
+ const url = window.URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = 'legal_research_report.docx'
+ document.body.appendChild(a)
+ a.click()
+ window.URL.revokeObjectURL(url)
+ document.body.removeChild(a)
+ toast.success("Word document downloaded!")
+ } else {
+ toast.error("Failed to generate Word document")
+ }
+ } catch (error) {
+ console.error("Error generating Word document:", error)
+ toast.error("Error generating Word document")
+ }
+ }
+
+ const sanitizeForSharing = (content: string) => {
+ if (!content) return ""
+ return content.replace(/<[^>]+>/g, "").trim()
+ }
+
+ const ensureSharePreview = async (message: Message, platform: SharePlatform) => {
+ const cached = shareCache[message.id]?.[platform]
+ if (cached) return cached
+
+ try {
+ setShareSheet((prev) =>
+ prev?.messageId === message.id
+ ? { ...prev, isLoading: true, shareError: null, success: null, shareLink: null }
+ : prev
+ )
+
+ const response = await fetch(`${API_BASE_URL}/share/format`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ answer: sanitizeForSharing(message.content),
+ sources: message.sources || [],
+ platform,
+ query: findPreviousUserPrompt(message.id),
+ include_hashtags: true
+ })
+ })
+
+ if (!response.ok) {
+ const detail = await response.text()
+ throw new Error(detail || "Unable to format response")
+ }
+
+ const data: ShareFormatResponse = await response.json()
+ setShareCache((prev) => ({
+ ...prev,
+ [message.id]: { ...(prev[message.id] || {}), [platform]: data }
+ }))
+ setShareSheet((prev) =>
+ prev?.messageId === message.id ? { ...prev, preview: data, isLoading: false } : prev
+ )
+ return data
+ } catch (error) {
+ console.error("Failed to format post:", error)
+ setShareSheet((prev) =>
+ prev?.messageId === message.id
+ ? {
+ ...prev,
+ isLoading: false,
+ shareError:
+ error instanceof Error ? error.message : "Unable to format this response right now."
+ }
+ : prev
+ )
+ return null
+ }
+ }
+
+ const openShareSheet = (message: Message, platform: SharePlatform = "twitter") => {
+ if (message.role !== "assistant") return
+
+ if (shareSheet?.messageId === message.id && shareSheet.platform === platform) {
+ setShareSheet(null)
+ return
+ }
+
+ const cached = shareCache[message.id]?.[platform]
+ setShareSheet({
+ messageId: message.id,
+ platform,
+ preview: cached,
+ isLoading: !cached,
+ shareLink: null,
+ shareError: null,
+ success: null
+ })
+
+ if (!cached) {
+ ensureSharePreview(message, platform)
+ }
+ }
+
+ const changeSharePlatform = (message: Message, platform: SharePlatform) => {
+ if (!shareSheet || shareSheet.messageId !== message.id) {
+ openShareSheet(message, platform)
+ return
+ }
+
+ const cached = shareCache[message.id]?.[platform]
+ setShareSheet({
+ ...shareSheet,
+ platform,
+ preview: cached,
+ isLoading: !cached,
+ shareLink: null,
+ shareError: null,
+ success: null
+ })
+
+ if (!cached) {
+ ensureSharePreview(message, platform)
+ }
+ }
+
+ const copyShareContent = async () => {
+ if (!shareSheet?.preview) return
+ const preview = shareSheet.preview
+ const text = Array.isArray(preview.content) ? preview.content.join("\n\n") : preview.content
+
+ try {
+ await navigator.clipboard.writeText(text)
+ toast.success("Formatted answer copied!")
+ } catch (error) {
+ console.error("Failed to copy share content:", error)
+ toast.error("Unable to copy content")
+ }
+ }
+
+ const openShareIntent = async (message: Message) => {
+ if (!shareSheet) return
+ const preview = shareSheet.preview ?? (await ensureSharePreview(message, shareSheet.platform))
+ if (!preview) return
+
+ try {
+ setShareSheet((prev) =>
+ prev ? { ...prev, shareLinkLoading: true, shareError: null, success: null } : prev
+ )
+ const sessionLink = await shareChat({ silent: true })
+
+ const response = await fetch(`${API_BASE_URL}/share/generate-link`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ platform: shareSheet.platform,
+ content: preview.content,
+ url: sessionLink || undefined
+ })
+ })
+
+ if (!response.ok) {
+ const detail = await response.text()
+ throw new Error(detail || "Unable to open share dialog")
+ }
+
+ const data = await response.json()
+ setShareSheet((prev) =>
+ prev
+ ? {
+ ...prev,
+ shareLink: data.share_url,
+ shareLinkLoading: false,
+ success: `Share dialog ready for ${shareSheet.platform}`
+ }
+ : prev
+ )
+ window.open(data.share_url, "_blank", "noopener,noreferrer")
+ } catch (error) {
+ console.error("Failed to open share link:", error)
+ setShareSheet((prev) =>
+ prev
+ ? {
+ ...prev,
+ shareLinkLoading: false,
+ shareError:
+ error instanceof Error
+ ? error.message
+ : "Unable to open the platform share dialog."
+ }
+ : prev
+ )
+ }
+ }
+
+ const generateShareImage = async (message: Message) => {
+ if (!shareSheet) return
+ const preview = shareSheet.preview ?? (await ensureSharePreview(message, shareSheet.platform))
+ if (!preview) return
+
+ try {
+ setShareSheet((prev) =>
+ prev ? { ...prev, generatingImage: true, shareError: null, success: null } : prev
+ )
+
+ const text = Array.isArray(preview.content) ? preview.content.join("\n\n") : preview.content
+ const title = `AmaniQuery Insight`
+
+ const response = await fetch(`${API_BASE_URL}/share/generate-image-from-post`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ post_content: text,
+ query: title,
+ color_scheme: "professional"
+ })
+ })
+
+ if (!response.ok) {
+ const detail = await response.text()
+ throw new Error(detail || "Unable to generate image")
+ }
+
+ const data = await response.json()
+
+ // Download the image
+ const imageResponse = await fetch(`data:image/png;base64,${data.image_base64}`)
+ const blob = await imageResponse.blob()
+ const url = URL.createObjectURL(blob)
+
+ const a = document.createElement('a')
+ a.href = url
+ a.download = `amaniquery-${message.id}.png`
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+
+ setShareSheet((prev) =>
+ prev
+ ? {
+ ...prev,
+ generatingImage: false,
+ success: "Image downloaded successfully!",
+ shareError: null
+ }
+ : prev
+ )
+ } catch (error) {
+ console.error("Failed to generate image:", error)
+ setShareSheet((prev) =>
+ prev
+ ? {
+ ...prev,
+ generatingImage: false,
+ shareError:
+ error instanceof Error
+ ? error.message
+ : "Unable to generate image."
+ }
+ : prev
+ )
+ }
+ }
+
+ const postDirectly = async (message: Message) => {
+ if (!shareSheet) return
+ const preview = shareSheet.preview ?? (await ensureSharePreview(message, shareSheet.platform))
+ if (!preview) return
+
+ // Check if user is authenticated
+ const accessToken = getStoredToken(shareSheet.platform)
+ if (!accessToken) {
+ setShareSheet(prev => prev ? {
+ ...prev,
+ shareError: `Please authenticate with ${shareSheet.platform} first. Click the authenticate button below.`
+ } : prev)
+ return
+ }
+
+ try {
+ setShareSheet((prev) =>
+ prev ? { ...prev, posting: true, shareError: null, success: null } : prev
+ )
+
+ const response = await fetch(`${API_BASE_URL}/share/post`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ platform: shareSheet.platform,
+ content: preview.content,
+ message_id: message.id,
+ access_token: accessToken
+ })
+ })
+
+ if (!response.ok) {
+ const detail = await response.text()
+ throw new Error(detail || "Unable to post")
+ }
+
+ const data = await response.json()
+ console.log('Post response:', data)
+ setShareSheet((prev) =>
+ prev
+ ? {
+ ...prev,
+ posting: false,
+ success: data.message || `Posted to ${shareSheet.platform} successfully`,
+ shareError: null
+ }
+ : prev
+ )
+ toast.success(data.message || `Posted to ${shareSheet.platform} successfully`)
+ } catch (error) {
+ console.error("Failed to post to platform:", error)
+ setShareSheet((prev) =>
+ prev
+ ? {
+ ...prev,
+ posting: false,
+ shareError:
+ error instanceof Error
+ ? error.message
+ : "Unable to post automatically. Try the share dialog instead."
+ }
+ : prev
+ )
+ }
+ }
+
+ return (
+
+
createNewSession()}
+ onCloseHistory={() => setShowHistory(false)}
+ />
+
+
+
setShowHistory(!showHistory)}
+ onShare={() => shareChat()}
+ HistoryIcon={HistoryIcon}
+ />
+
+
+
setShareSheet(null)}
+ onChangeSharePlatform={changeSharePlatform}
+ onCopyShareContent={copyShareContent}
+ onOpenShareIntent={openShareIntent}
+ onPostDirectly={postDirectly}
+ onGenerateShareImage={generateShareImage}
+ onAuthenticatePlatform={initiateAuth}
+ platformTokens={platformTokens}
+ onCopyFailedQuery={copyFailedQuery}
+ onEditFailedQuery={editFailedQuery}
+ onResendFailedQuery={resendFailedQuery}
+ />
+
+
+ {
+ setIsResearchMode(!isResearchMode)
+ setUseHybrid(false)
+ }}
+ onToggleHybrid={() => {
+ setUseHybrid(!useHybrid)
+ setIsResearchMode(false)
+ }}
+ />
+
+
+ )
+}
diff --git a/src/components/chat/AmaniChat.tsx b/src/components/chat/AmaniChat.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..09f1eca0e21de54565498b1ce0f8c20ea3264ca7
--- /dev/null
+++ b/src/components/chat/AmaniChat.tsx
@@ -0,0 +1,1235 @@
+"use client"
+
+import { useState, useEffect, useCallback, useRef } from "react"
+import { cn } from "@/lib/utils"
+import { toast } from "sonner"
+import { AmaniMessageList } from "./AmaniMessageList"
+import { AmaniInput } from "./AmaniInput"
+import { ChatHeader } from "./ChatHeader"
+import { StreamingMessage } from "./AmaniMessageList"
+import type { Source } from "./types"
+import type {
+ Message,
+ ChatSession,
+ SharePlatform,
+ ShareFormatResponse,
+ ShareSheetState,
+ StreamMetadata
+} from "./types"
+
+// Extend Window interface for OAuth callback
+declare global {
+ interface Window {
+ handleOAuthCallback?: (platform: SharePlatform, code: string, state: string) => Promise
+ }
+}
+
+interface AmaniChatProps {
+ className?: string
+ showWelcomeScreen?: boolean
+ enableThinkingIndicator?: boolean
+ showInlineSources?: boolean
+ enableVoice?: boolean
+ currentSessionId?: string | null
+ onSessionChange?: (sessionId: string | null) => void
+ chatHistory?: ChatSession[]
+ onChatHistoryUpdate?: (sessions: ChatSession[]) => void
+ onLoadSession?: (sessionId: string) => void
+ onDeleteSession?: (sessionId: string) => void
+ onToggleSidebar?: () => void
+}
+
+export function AmaniChat({
+ className,
+ showWelcomeScreen = true,
+ enableThinkingIndicator = true,
+ showInlineSources = true,
+ enableVoice = false,
+ currentSessionId: externalSessionId,
+ onSessionChange,
+ chatHistory: externalChatHistory,
+ onChatHistoryUpdate,
+ onLoadSession,
+ onDeleteSession,
+ onToggleSidebar
+}: AmaniChatProps) {
+ const [messages, setMessages] = useState([])
+ const [input, setInput] = useState("")
+ const [isLoading, setIsLoading] = useState(false)
+ const [isThinking, setIsThinking] = useState(false)
+ const [internalSessionId, setInternalSessionId] = useState(null)
+ const [useHybrid, setUseHybrid] = useState(false)
+ const [isResearchMode, setIsResearchMode] = useState(false)
+ const [internalChatHistory, setInternalChatHistory] = useState([])
+ const [selectedFiles, setSelectedFiles] = useState([])
+ const [uploadingFiles, setUploadingFiles] = useState(false)
+ const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([])
+ const [showAutocomplete, setShowAutocomplete] = useState(false)
+ const [isVoiceActive, setIsVoiceActive] = useState(false)
+ const [streamingContent, setStreamingContent] = useState("")
+ const [streamingSources, setStreamingSources] = useState([])
+ const [showHistory, setShowHistory] = useState(false)
+ const [editingMessageId, setEditingMessageId] = useState(null)
+ const [editingContent, setEditingContent] = useState("")
+ const [shareSheet, setShareSheet] = useState(null)
+ const [shareCache, setShareCache] = useState<
+ Record>>
+ >({})
+ const [platformTokens, setPlatformTokens] = useState>({
+ twitter: null,
+ linkedin: null,
+ facebook: null,
+ whatsapp: null,
+ telegram: null,
+ email: null,
+ threads: null,
+ bluesky: null,
+ tiktok: null
+ })
+
+ // Use external props if provided, otherwise use internal state
+ const currentSessionId = externalSessionId ?? internalSessionId
+ const chatHistory = externalChatHistory ?? internalChatHistory
+
+ const autocompleteTimeoutRef = useRef(null)
+ const messagesContainerRef = useRef(null)
+
+ const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+ const ENABLE_AUTOCOMPLETE = process.env.NEXT_PUBLIC_ENABLE_AUTOCOMPLETE !== "false"
+
+ // Helper function to get auth headers
+ const getAuthHeaders = useCallback((): Record => {
+ const token = localStorage.getItem("session_token")
+ return token ? { "X-Session-Token": token } : {}
+ }, [])
+
+ // Token management
+ const storeToken = useCallback((platform: SharePlatform, token: string) => {
+ localStorage.setItem(`${platform}_access_token`, token)
+ setPlatformTokens(prev => ({ ...prev, [platform]: token }))
+ }, [])
+
+ const clearToken = useCallback((platform: SharePlatform) => {
+ localStorage.removeItem(`${platform}_access_token`)
+ setPlatformTokens(prev => ({ ...prev, [platform]: null }))
+ }, [])
+
+ const loadStoredTokens = useCallback(() => {
+ const tokens: Record = {
+ twitter: localStorage.getItem("twitter_access_token"),
+ linkedin: localStorage.getItem("linkedin_access_token"),
+ facebook: localStorage.getItem("facebook_access_token"),
+ whatsapp: null,
+ telegram: null,
+ email: null,
+ threads: localStorage.getItem("threads_access_token"),
+ bluesky: localStorage.getItem("bluesky_access_token"),
+ tiktok: localStorage.getItem("tiktok_access_token")
+ }
+ setPlatformTokens(tokens)
+ }, [])
+
+ const getStoredToken = useCallback((platform: SharePlatform) => {
+ return localStorage.getItem(`${platform}_access_token`)
+ }, [])
+
+ // Load chat history
+ const loadChatHistory = useCallback(async () => {
+ try {
+ const headers = { "Content-Type": "application/json", ...getAuthHeaders() }
+ const response = await fetch(`/api/cache/sessions`, { headers })
+ if (response.ok) {
+ const sessions = await response.json()
+ if (onChatHistoryUpdate) {
+ onChatHistoryUpdate(sessions)
+ } else {
+ setInternalChatHistory(sessions)
+ }
+ }
+ } catch (error) {
+ console.error("Failed to load chat history:", error)
+ }
+ }, [getAuthHeaders, onChatHistoryUpdate])
+
+ // Create new session
+ const createNewSession = useCallback(async (firstMessage?: string) => {
+ let title = "New Chat"
+ if (firstMessage) {
+ const content = firstMessage.trim()
+ title = content.length <= 50 ? content : content.substring(0, 50).split(' ').slice(0, -1).join(' ') + "..."
+ }
+
+ try {
+ const headers = { "Content-Type": "application/json", ...getAuthHeaders() }
+ const response = await fetch(`${API_BASE_URL}/api/v1/chat/sessions`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ title })
+ })
+ if (response.ok) {
+ const session = await response.json()
+ if (onSessionChange) {
+ onSessionChange(session.id)
+ } else {
+ setInternalSessionId(session.id)
+ }
+ setMessages([])
+ loadChatHistory()
+ return session.id
+ }
+ } catch (error) {
+ console.error("Failed to create session:", error)
+ }
+ return null
+ }, [getAuthHeaders, API_BASE_URL, loadChatHistory, onSessionChange])
+
+ // Load session - Available for parent components via onLoadSession callback
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const loadSession = useCallback(async (sessionId: string) => {
+ console.log('[AmaniChat] loadSession called with sessionId:', sessionId)
+ try {
+ // Call parent callback if provided
+ if (onLoadSession) {
+ console.log('[AmaniChat] Using parent onLoadSession callback')
+ onLoadSession(sessionId)
+ return
+ }
+
+ // Otherwise handle internally
+ const headers = { "Content-Type": "application/json", ...getAuthHeaders() }
+ const url = `${API_BASE_URL}/api/v1/chat/sessions/${sessionId}`
+ console.log('[AmaniChat] Fetching session from:', url)
+ const response = await fetch(url, { headers })
+ console.log('[AmaniChat] Response status:', response.status)
+ if (response.ok) {
+ const session = await response.json()
+ console.log('[AmaniChat] Session data received:', session)
+ console.log('[AmaniChat] Messages count:', session.messages?.length || 0)
+ if (onSessionChange) {
+ onSessionChange(sessionId)
+ } else {
+ setInternalSessionId(sessionId)
+ }
+ setMessages(session.messages || [])
+ } else {
+ console.error('[AmaniChat] Failed to fetch session, status:', response.status)
+ }
+ } catch (error) {
+ console.error("Failed to load session:", error)
+ }
+ }, [getAuthHeaders, API_BASE_URL, onSessionChange, onLoadSession])
+
+ // Delete session - Available for parent components via onDeleteSession callback
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const deleteSession = useCallback(async (sessionId: string) => {
+ try {
+ // Call parent callback if provided
+ if (onDeleteSession) {
+ onDeleteSession(sessionId)
+ return
+ }
+
+ // Otherwise handle internally
+ const headers = { ...getAuthHeaders() }
+ const response = await fetch(`${API_BASE_URL}/api/v1/chat/sessions/${sessionId}`, {
+ method: "DELETE",
+ headers
+ })
+ if (response.ok) {
+ if (onChatHistoryUpdate) {
+ // Remove session from external history
+ const updatedHistory = chatHistory.filter(s => s.id !== sessionId)
+ onChatHistoryUpdate(updatedHistory)
+ } else {
+ // Update internal history
+ loadChatHistory()
+ }
+
+ if (currentSessionId === sessionId) {
+ if (onSessionChange) {
+ onSessionChange(null)
+ } else {
+ setInternalSessionId(null)
+ }
+ setMessages([])
+ }
+ }
+ } catch (error) {
+ console.error("Failed to delete session:", error)
+ }
+ }, [getAuthHeaders, API_BASE_URL, currentSessionId, loadChatHistory, onChatHistoryUpdate, chatHistory, onSessionChange, onDeleteSession])
+
+ // Autocomplete functionality
+ const fetchAutocomplete = useCallback(async (query: string) => {
+ if (!ENABLE_AUTOCOMPLETE || !query.trim() || query.length < 2) {
+ setAutocompleteSuggestions([])
+ setShowAutocomplete(false)
+ return
+ }
+
+ try {
+ const response = await fetch(
+ `${API_BASE_URL}/api/autocomplete?q=${encodeURIComponent(query)}&max_results=5&location=Kenya&language=en`
+ )
+ if (response.ok) {
+ const data = await response.json()
+ const suggestions = data.suggestions?.map((s: { text: string }) => s.text) || []
+ setAutocompleteSuggestions(suggestions)
+ setShowAutocomplete(suggestions.length > 0)
+ }
+ } catch (error) {
+ console.error("Failed to fetch autocomplete:", error)
+ setAutocompleteSuggestions([])
+ setShowAutocomplete(false)
+ }
+ }, [API_BASE_URL, ENABLE_AUTOCOMPLETE])
+
+ // Debounced autocomplete
+ useEffect(() => {
+ if (!ENABLE_AUTOCOMPLETE) return
+
+ if (autocompleteTimeoutRef.current) {
+ clearTimeout(autocompleteTimeoutRef.current)
+ }
+
+ if (input.trim().length >= 2) {
+ autocompleteTimeoutRef.current = setTimeout(() => {
+ fetchAutocomplete(input)
+ }, 300)
+ } else {
+ setAutocompleteSuggestions([])
+ setShowAutocomplete(false)
+ }
+
+ return () => {
+ if (autocompleteTimeoutRef.current) {
+ clearTimeout(autocompleteTimeoutRef.current)
+ }
+ }
+ }, [input, fetchAutocomplete, ENABLE_AUTOCOMPLETE])
+
+ // File upload handling
+ const handleFileSelect = useCallback(async (files: File[]) => {
+ if (!currentSessionId) {
+ const newSessionId = await createNewSession()
+ if (!newSessionId) {
+ toast.error("Failed to create session for file upload")
+ return
+ }
+ }
+
+ setUploadingFiles(true)
+ const uploadedFiles: File[] = []
+
+ try {
+ for (const file of files) {
+ const formData = new FormData()
+ formData.append("file", file)
+
+ const response = await fetch(`${API_BASE_URL}/api/v1/chat/sessions/${currentSessionId}/attachments`, {
+ method: "POST",
+ headers: getAuthHeaders(),
+ body: formData
+ })
+
+ if (response.ok) {
+ uploadedFiles.push(file)
+ toast.success(`Uploaded ${file.name}`)
+ } else {
+ toast.error(`Failed to upload ${file.name}`)
+ }
+ }
+
+ setSelectedFiles(prev => [...prev, ...uploadedFiles])
+ } catch (error) {
+ console.error("File upload failed:", error)
+ toast.error("File upload failed")
+ } finally {
+ setUploadingFiles(false)
+ }
+ }, [currentSessionId, createNewSession, API_BASE_URL, getAuthHeaders])
+
+ // Send message
+ const sendMessage = useCallback(async (customContent?: string) => {
+ const contentToSend = customContent || input
+ if (!contentToSend.trim() || isLoading) return
+
+ let sessionId = currentSessionId
+ if (!sessionId) {
+ sessionId = await createNewSession(contentToSend)
+ if (!sessionId) {
+ toast.error("Failed to create chat session")
+ return
+ }
+ }
+
+ const userMessage: Message = {
+ id: Date.now().toString(),
+ session_id: sessionId,
+ role: "user",
+ content: input,
+ created_at: new Date().toISOString(),
+ attachments: selectedFiles.length > 0 ? selectedFiles.map(file => ({
+ id: file.name,
+ filename: file.name,
+ file_type: file.type,
+ file_size: file.size,
+ uploaded_at: new Date().toISOString(),
+ processed: true
+ })) : undefined
+ }
+
+ setMessages(prev => [...prev, userMessage])
+ setInput("")
+ setSelectedFiles([])
+ setIsLoading(true)
+ setIsThinking(true)
+ setStreamingContent("")
+ setStreamingSources([])
+
+ try {
+ const headers = { "Content-Type": "application/json", ...getAuthHeaders() }
+ const response = await fetch(`${API_BASE_URL}/api/v1/chat/completions`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ session_id: sessionId,
+ message: input,
+ use_hybrid: useHybrid,
+ is_research: isResearchMode,
+ attachments: selectedFiles.length > 0 ? selectedFiles.map(file => ({
+ filename: file.name,
+ file_type: file.type,
+ file_size: file.size
+ })) : undefined
+ })
+ })
+
+ if (!response.ok) {
+ throw new Error("Failed to send message")
+ }
+
+ const reader = response.body?.getReader()
+ const decoder = new TextDecoder()
+ let assistantContent = ""
+ let sources: Source[] = []
+ let metadata: StreamMetadata = {}
+
+ while (true) {
+ const { done, value } = await reader!.read()
+ if (done) break
+
+ const chunk = decoder.decode(value)
+ const lines = chunk.split('\n').filter(line => line.trim())
+
+ for (const line of lines) {
+ if (line.startsWith('data: ')) {
+ const data = line.slice(6)
+ if (data === '[DONE]') {
+ setIsThinking(false)
+ continue
+ }
+
+ try {
+ const parsed = JSON.parse(data)
+ if (parsed.content) {
+ assistantContent += parsed.content
+ setStreamingContent(assistantContent)
+ }
+ if (parsed.sources) {
+ sources = parsed.sources
+ setStreamingSources(sources)
+ }
+ if (parsed.metadata) {
+ metadata = { ...metadata, ...parsed.metadata }
+ }
+ } catch (error) {
+ console.error("Failed to parse SSE data:", error)
+ }
+ }
+ }
+ }
+
+ const assistantMessage: Message = {
+ id: (Date.now() + 1).toString(),
+ session_id: sessionId,
+ role: "assistant",
+ content: assistantContent,
+ created_at: new Date().toISOString(),
+ sources: sources,
+ token_count: metadata.token_count,
+ model_used: metadata.model_used
+ }
+
+ setMessages(prev => [...prev, assistantMessage])
+
+ } catch (error) {
+ console.error("Failed to send message:", error)
+ toast.error("Failed to send message")
+
+ const errorMessage: Message = {
+ id: (Date.now() + 1).toString(),
+ session_id: sessionId,
+ role: "assistant",
+ content: "Sorry, I encountered an error while processing your message. Please try again.",
+ created_at: new Date().toISOString(),
+ failed: true
+ }
+
+ setMessages(prev => [...prev, errorMessage])
+ } finally {
+ setIsLoading(false)
+ setIsThinking(false)
+ setStreamingContent("")
+ setStreamingSources([])
+ }
+ }, [input, isLoading, currentSessionId, createNewSession, useHybrid, isResearchMode, selectedFiles, API_BASE_URL, getAuthHeaders])
+
+ // Handle message actions
+ const handleCopy = useCallback(async (content: string) => {
+ try {
+ await navigator.clipboard.writeText(content)
+ toast.success("Message copied to clipboard")
+ } catch (error) {
+ console.error("Failed to copy message:", error)
+ toast.error("Failed to copy message")
+ }
+ }, [])
+
+ const handleRegenerate = useCallback(async (messageId: string) => {
+ const messageIndex = messages.findIndex(m => m.id === messageId)
+ if (messageIndex === -1) return
+
+ const userMessage = messages.slice(0, messageIndex + 1).reverse().find(m => m.role === "user")
+ if (!userMessage) return
+
+ // Remove the assistant message and any messages after it
+ setMessages(prev => prev.slice(0, messageIndex))
+ setInput(userMessage.content)
+
+ // Trigger regeneration
+ setTimeout(() => {
+ sendMessage()
+ }, 100)
+ }, [messages, sendMessage])
+
+ const handleFeedback = useCallback(async (messageId: string, type: "like" | "dislike") => {
+ try {
+ const headers = { "Content-Type": "application/json", ...getAuthHeaders() }
+ const response = await fetch(`${API_BASE_URL}/api/v1/chat/feedback`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ message_id: messageId, feedback_type: type })
+ })
+
+ if (response.ok) {
+ setMessages(prev => prev.map(msg =>
+ msg.id === messageId ? { ...msg, feedback_type: type } : msg
+ ))
+ toast.success(`Feedback ${type} recorded`)
+ }
+ } catch (error) {
+ console.error("Failed to record feedback:", error)
+ toast.error("Failed to record feedback")
+ }
+ }, [API_BASE_URL, getAuthHeaders])
+
+ // Editing functions
+ const startEditingMessage = useCallback((message: Message) => {
+ if (message.role !== "user") return
+ setEditingMessageId(message.id)
+ setEditingContent(message.content)
+ }, [])
+
+ const cancelEditing = useCallback(() => {
+ setEditingMessageId(null)
+ setEditingContent("")
+ }, [])
+
+ const saveEditedMessage = useCallback(async (messageId: string) => {
+ if (!editingContent.trim()) return
+
+ const messageIndex = messages.findIndex(m => m.id === messageId)
+ if (messageIndex === -1) return
+
+ setMessages(prev => prev.slice(0, messageIndex))
+
+ setEditingMessageId(null)
+ setEditingContent("")
+ await sendMessage(editingContent) // This assumes sendMessage uses input state? No, sendMessage uses 'input' state.
+ // wait, sendMessage uses 'input' state if no arg passed, but I can modify sendMessage or setInput then sendMessage
+ // Actually sendMessage in AmaniChat (line 310) uses 'input' state.
+ // I should update sendMessage to accept an optional content arg?
+ }, [messages, editingContent]) // Wait, I need to fix sendMessage usage.
+
+ // Using a refined sendMessage wrapper or updating logic in saveEditedMessage:
+ // Since sendMessage uses 'input' state, I can setInput(editingContent) then call sendMessage?
+ // But sendMessage validates input.trim().
+
+ // Failed Query Functions
+ const copyFailedQuery = useCallback(async (message: Message) => {
+ const query = message.originalQuery || message.content
+ try {
+ await navigator.clipboard.writeText(query)
+ toast.success("Query copied to clipboard!")
+ } catch (error) {
+ console.error("Failed to copy query:", error)
+ toast.error("Failed to copy query")
+ }
+ }, [])
+
+ const editFailedQuery = useCallback((message: Message) => {
+ const query = message.originalQuery || message.content
+ setInput(query)
+ const textarea = document.querySelector('textarea')
+ if (textarea) textarea.focus()
+ }, [])
+
+ const resendFailedQuery = useCallback(async (message: Message) => {
+ const query = message.originalQuery || (message.role === "user" ? message.content : "")
+ if (!query.trim()) return
+
+ setMessages(prev => prev.filter(msg =>
+ msg.id !== message.id &&
+ !(msg.failed && (msg.originalQuery === query || (msg.role === "user" && msg.content === query)))
+ ))
+
+ setInput(query)
+ // Wait for state update is tricky.
+ // Ideally sendMessage should take an argument.
+ // I will modify sendMessage below.
+ }, [])
+
+ // Sharing functionality
+ const findPreviousUserPrompt = useCallback(
+ (messageId: string) => {
+ const index = messages.findIndex((msg) => msg.id === messageId)
+ if (index === -1) return undefined
+ for (let i = index - 1; i >= 0; i--) {
+ if (messages[i].role === "user") {
+ return messages[i].content
+ }
+ }
+ return undefined
+ },
+ [messages]
+ )
+
+ const sanitizeForSharing = (content: string): string => {
+ return content.trim()
+ }
+
+ const ensureSharePreview = useCallback(async (message: Message, platform: SharePlatform) => {
+ const cached = shareCache[message.id]?.[platform]
+ if (cached) return cached
+
+ try {
+ setShareSheet((prev) =>
+ prev?.messageId === message.id
+ ? { ...prev, isLoading: true, shareError: null, success: null, shareLink: null }
+ : prev
+ )
+
+ const response = await fetch(`${API_BASE_URL}/share/format`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", ...getAuthHeaders() },
+ body: JSON.stringify({
+ answer: sanitizeForSharing(message.content),
+ sources: message.sources || [],
+ platform,
+ query: findPreviousUserPrompt(message.id),
+ include_hashtags: true
+ })
+ })
+
+ if (!response.ok) {
+ const detail = await response.text()
+ throw new Error(detail || "Unable to format response")
+ }
+
+ const data: ShareFormatResponse = await response.json()
+ setShareCache((prev) => ({
+ ...prev,
+ [message.id]: { ...(prev[message.id] || {}), [platform]: data }
+ }))
+ setShareSheet((prev) =>
+ prev?.messageId === message.id ? { ...prev, preview: data, isLoading: false } : prev
+ )
+ return data
+ } catch (error) {
+ console.error("Failed to format post:", error)
+ setShareSheet((prev) =>
+ prev?.messageId === message.id
+ ? {
+ ...prev,
+ isLoading: false,
+ shareError:
+ error instanceof Error ? error.message : "Unable to format this response right now."
+ }
+ : prev
+ )
+ return null
+ }
+ }, [shareCache, API_BASE_URL, getAuthHeaders, findPreviousUserPrompt])
+
+ const handleShare = useCallback(async (message: Message, platform: SharePlatform = "twitter") => {
+ if (message.role !== "assistant") return
+
+ if (shareSheet?.messageId === message.id && shareSheet.platform === platform) {
+ setShareSheet(null)
+ return
+ }
+
+ const cached = shareCache[message.id]?.[platform]
+ setShareSheet({
+ messageId: message.id,
+ platform,
+ preview: cached,
+ isLoading: !cached,
+ shareLink: null,
+ shareError: null,
+ success: null
+ })
+
+ if (!cached) {
+ await ensureSharePreview(message, platform)
+ }
+ }, [shareSheet, shareCache, ensureSharePreview])
+
+ const copyShareContent = useCallback(async () => {
+ if (!shareSheet?.preview) return
+ const preview = shareSheet.preview
+ const text = Array.isArray(preview.content) ? preview.content.join("\n\n") : preview.content
+
+ try {
+ await navigator.clipboard.writeText(text)
+ toast.success("Formatted answer copied!")
+ } catch (error) {
+ console.error("Failed to copy share content:", error)
+ toast.error("Unable to copy content")
+ }
+ }, [shareSheet])
+
+ const changeSharePlatform = useCallback((message: Message, platform: SharePlatform) => {
+ if (!shareSheet || shareSheet.messageId !== message.id) {
+ // Logic to just open share sheet if needed, but handleShare handles opening.
+ // Here we switch platform
+ handleShare(message, platform)
+ return
+ }
+
+ const cached = shareCache[message.id]?.[platform]
+ setShareSheet({
+ ...shareSheet,
+ platform,
+ preview: cached,
+ isLoading: !cached,
+ shareLink: null,
+ shareError: null,
+ success: null
+ })
+
+ if (!cached) {
+ ensureSharePreview(message, platform)
+ }
+ }, [shareSheet, shareCache, handleShare, ensureSharePreview])
+
+ // Auth functions
+ const initiateAuth = useCallback(async (platform: SharePlatform) => {
+ try {
+ setShareSheet(prev => prev ? { ...prev, shareLinkLoading: true, shareError: null } : prev)
+
+ const response = await fetch(`${API_BASE_URL}/share/auth`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ platform })
+ })
+
+ if (!response.ok) {
+ const error = await response.json()
+ throw new Error(error.detail || "Failed to initiate authentication")
+ }
+
+ const data = await response.json()
+
+ if (data.auth_url) {
+ // For OAuth 2.0 (LinkedIn, etc)
+ const width = 600
+ const height = 700
+ const left = window.screen.width / 2 - width / 2
+ const top = window.screen.height / 2 - height / 2
+
+ window.open(
+ data.auth_url,
+ `Authenticate ${platform}`,
+ `width=${width},height=${height},left=${left},top=${top}`
+ )
+
+ // Poll for auth success
+ const checkAuth = setInterval(async () => {
+ const isAuthenticated = await checkAuthStatus(platform)
+ if (isAuthenticated) {
+ clearInterval(checkAuth)
+ setShareSheet(prev => prev ? { ...prev, shareLinkLoading: false } : prev)
+ }
+ }, 2000)
+
+ // Clear interval after 2 minutes
+ setTimeout(() => clearInterval(checkAuth), 120000)
+
+ } else if (data.status === "authenticated") {
+ if (data.user_info?.access_token) {
+ storeToken(platform, data.user_info.access_token)
+ }
+ toast.success(`Already authenticated with ${platform}`)
+ setShareSheet(prev => prev ? { ...prev, shareLinkLoading: false } : prev)
+ }
+ } catch (error) {
+ console.error("Failed to initiate auth:", error)
+ setShareSheet(prev => prev ? {
+ ...prev,
+ shareLinkLoading: false,
+ shareError: error instanceof Error ? error.message : "Authentication failed"
+ } : prev)
+ }
+ }, [API_BASE_URL, storeToken])
+
+ const checkAuthStatus = useCallback(async (platform: SharePlatform) => {
+ try {
+ const response = await fetch(`${API_BASE_URL}/share/auth/status`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ platform })
+ })
+
+ if (response.ok) {
+ const data = await response.json()
+ if (data.status === "authenticated" && data.user_info?.access_token) {
+ storeToken(platform, data.user_info.access_token)
+ toast.success(`Successfully authenticated with ${platform}`)
+ return true
+ } else {
+ clearToken(platform)
+ }
+ } else {
+ clearToken(platform)
+ }
+ } catch (error) {
+ console.error("Failed to check auth status:", error)
+ clearToken(platform)
+ }
+ return false
+ }, [API_BASE_URL, storeToken, clearToken])
+
+ // Share Actions
+ const shareChatLink = useCallback(async (options: { silent?: boolean } = {}) => {
+ if (!currentSessionId) return null
+
+ try {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ ...getAuthHeaders()
+ }
+ const response = await fetch(
+ `${API_BASE_URL}/api/v1/chat/share?session_id=${encodeURIComponent(currentSessionId)}`,
+ {
+ method: "POST",
+ headers
+ }
+ )
+ if (response.ok) {
+ const data = await response.json()
+ const shareUrl = `${window.location.origin}${data.share_link}`
+ if (!options.silent) {
+ await navigator.clipboard.writeText(shareUrl)
+ toast.success("Share link copied to clipboard!")
+ }
+ return shareUrl
+ } else {
+ if (!options.silent) {
+ toast.error("Failed to generate share link")
+ }
+ }
+ } catch (error) {
+ console.error("Failed to generate share link:", error)
+ if (!options.silent) {
+ toast.error("Failed to generate share link")
+ }
+ }
+ return null
+ }, [currentSessionId, API_BASE_URL, getAuthHeaders])
+
+ const openShareIntent = useCallback(async (message: Message) => {
+ if (!shareSheet) return
+ const preview = shareSheet.preview ?? (await ensureSharePreview(message, shareSheet.platform))
+ if (!preview) return
+
+ try {
+ setShareSheet((prev) =>
+ prev ? { ...prev, shareLinkLoading: true, shareError: null, success: null } : prev
+ )
+ const sessionLink = await shareChatLink({ silent: true })
+
+ const response = await fetch(`${API_BASE_URL}/share/generate-link`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ platform: shareSheet.platform,
+ content: preview.content,
+ url: sessionLink || undefined
+ })
+ })
+
+ if (!response.ok) {
+ const detail = await response.text()
+ throw new Error(detail || "Unable to open share dialog")
+ }
+
+ const data = await response.json()
+ setShareSheet((prev) =>
+ prev
+ ? {
+ ...prev,
+ shareLink: data.share_url,
+ shareLinkLoading: false,
+ success: `Share dialog ready for ${shareSheet.platform}`
+ }
+ : prev
+ )
+ window.open(data.share_url, "_blank", "noopener,noreferrer")
+ } catch (error) {
+ console.error("Failed to open share link:", error)
+ setShareSheet((prev) =>
+ prev
+ ? {
+ ...prev,
+ shareLinkLoading: false,
+ shareError:
+ error instanceof Error
+ ? error.message
+ : "Unable to open the platform share dialog."
+ }
+ : prev
+ )
+ }
+ }, [shareSheet, ensureSharePreview, shareChatLink, API_BASE_URL])
+
+ const generateShareImage = useCallback(async (message: Message) => {
+ if (!shareSheet) return
+ const preview = shareSheet.preview ?? (await ensureSharePreview(message, shareSheet.platform))
+ if (!preview) return
+
+ try {
+ setShareSheet((prev) =>
+ prev ? { ...prev, generatingImage: true, shareError: null, success: null } : prev
+ )
+
+ const text = Array.isArray(preview.content) ? preview.content.join("\n\n") : preview.content
+ const title = `AmaniQuery Insight`
+
+ const response = await fetch(`${API_BASE_URL}/share/generate-image-from-post`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ post_content: text,
+ query: title,
+ color_scheme: "professional"
+ })
+ })
+
+ if (!response.ok) {
+ const detail = await response.text()
+ throw new Error(detail || "Unable to generate image")
+ }
+
+ const data = await response.json()
+
+ // Download the image
+ const imageResponse = await fetch(`data:image/png;base64,${data.image_base64}`)
+ const blob = await imageResponse.blob()
+ const url = URL.createObjectURL(blob)
+
+ const a = document.createElement('a')
+ a.href = url
+ a.download = `amaniquery-${message.id}.png`
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+
+ setShareSheet((prev) =>
+ prev
+ ? {
+ ...prev,
+ generatingImage: false,
+ success: "Image downloaded successfully!",
+ shareError: null
+ }
+ : prev
+ )
+ } catch (error) {
+ console.error("Failed to generate image:", error)
+ setShareSheet((prev) =>
+ prev
+ ? {
+ ...prev,
+ generatingImage: false,
+ shareError:
+ error instanceof Error
+ ? error.message
+ : "Unable to generate image."
+ }
+ : prev
+ )
+ }
+ }, [shareSheet, ensureSharePreview, API_BASE_URL])
+
+ const postDirectly = useCallback(async (message: Message) => {
+ if (!shareSheet) return
+ const preview = shareSheet.preview ?? (await ensureSharePreview(message, shareSheet.platform))
+ if (!preview) return
+
+ const accessToken = getStoredToken(shareSheet.platform)
+ if (!accessToken) {
+ setShareSheet(prev => prev ? {
+ ...prev,
+ shareError: `Please authenticate with ${shareSheet.platform} first.`
+ } : prev)
+ return
+ }
+
+ try {
+ setShareSheet((prev) =>
+ prev ? { ...prev, posting: true, shareError: null, success: null } : prev
+ )
+
+ const response = await fetch(`${API_BASE_URL}/share/post`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ platform: shareSheet.platform,
+ content: preview.content,
+ message_id: message.id,
+ access_token: accessToken
+ })
+ })
+
+ if (!response.ok) {
+ const detail = await response.text()
+ throw new Error(detail || "Unable to post")
+ }
+
+ const data = await response.json()
+ setShareSheet((prev) =>
+ prev
+ ? {
+ ...prev,
+ posting: false,
+ success: data.message || `Posted to ${shareSheet.platform} successfully`,
+ shareError: null
+ }
+ : prev
+ )
+ toast.success(data.message || `Posted to ${shareSheet.platform} successfully`)
+ } catch (error) {
+ console.error("Failed to post to platform:", error)
+ setShareSheet((prev) =>
+ prev
+ ? {
+ ...prev,
+ posting: false,
+ shareError:
+ error instanceof Error
+ ? error.message
+ : "Unable to post automatically."
+ }
+ : prev
+ )
+ }
+ }, [shareSheet, ensureSharePreview, getStoredToken, API_BASE_URL])
+
+ // OAuth Callback Handler (for popup)
+ useEffect(() => {
+ window.handleOAuthCallback = async (platform: SharePlatform, code: string, state: string) => {
+ try {
+ const response = await fetch(`${API_BASE_URL}/share/auth/callback`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ platform, code, state })
+ })
+
+ if (response.ok) {
+ const data = await response.json()
+ if (data.access_token) {
+ storeToken(platform, data.access_token)
+ toast.success("Authentication successful!")
+ return true
+ }
+ }
+ } catch (error) {
+ console.error("Auth callback error:", error)
+ }
+ return false
+ }
+ }, [API_BASE_URL, storeToken])
+
+ // Load tokens on mount
+ useEffect(() => {
+ loadStoredTokens()
+ }, [loadStoredTokens])
+
+ // Scroll to bottom when messages change
+ useEffect(() => {
+ if (messagesContainerRef.current) {
+ messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight
+ }
+ }, [messages, streamingContent])
+
+ // Initialize
+ useEffect(() => {
+ loadChatHistory()
+ }, [loadChatHistory])
+
+ // Load session messages when currentSessionId changes
+ useEffect(() => {
+ console.log('[AmaniChat] useEffect triggered - currentSessionId:', currentSessionId)
+ if (currentSessionId) {
+ loadSession(currentSessionId)
+ } else {
+ setMessages([])
+ }
+ }, [currentSessionId, loadSession])
+
+ return (
+
+ {/* Header */}
+
setShowHistory(!showHistory)}
+ onToggleSidebar={onToggleSidebar}
+ useHybrid={useHybrid}
+ isResearchMode={isResearchMode}
+ isLoading={isLoading}
+ onShare={copyShareContent}
+ />
+
+ {/* Messages */}
+
+ {streamingContent ? (
+
+
setShareSheet(null)}
+ onChangeSharePlatform={changeSharePlatform}
+ onCopyShareContent={copyShareContent}
+ onGenerateShareImage={generateShareImage}
+ onOpenShareIntent={openShareIntent}
+ onAuthenticatePlatform={initiateAuth}
+ onPostDirectly={postDirectly}
+ platformTokens={platformTokens}
+ editingMessageId={editingMessageId}
+ editingContent={editingContent}
+ setEditingContent={setEditingContent}
+ onSaveEdit={saveEditedMessage}
+ onCancelEdit={cancelEditing}
+ onStartEdit={startEditingMessage}
+ onCopyFailedQuery={copyFailedQuery}
+ onEditFailedQuery={editFailedQuery}
+ onResendFailedQuery={resendFailedQuery}
+ />
+
+
+
+
+ ) : (
+
setShareSheet(null)}
+ onChangeSharePlatform={changeSharePlatform}
+ onCopyShareContent={copyShareContent}
+ onGenerateShareImage={generateShareImage}
+ onOpenShareIntent={openShareIntent}
+ onAuthenticatePlatform={initiateAuth}
+ onPostDirectly={postDirectly}
+ platformTokens={platformTokens}
+ editingMessageId={editingMessageId}
+ editingContent={editingContent}
+ setEditingContent={setEditingContent}
+ onSaveEdit={saveEditedMessage}
+ onCancelEdit={cancelEditing}
+ onStartEdit={startEditingMessage}
+ onCopyFailedQuery={copyFailedQuery}
+ onEditFailedQuery={editFailedQuery}
+ onResendFailedQuery={resendFailedQuery}
+ />
+ )}
+
+
+ {/* Input */}
+
+
{
+ setUseHybrid(mode === "hybrid")
+ setIsResearchMode(mode === "research")
+ }}
+ placeholder="Ask AmaniQuery anything..."
+ disabled={isLoading || uploadingFiles}
+ isLoading={isLoading || uploadingFiles}
+ mode={isResearchMode ? "research" : useHybrid ? "hybrid" : "chat"}
+ attachments={selectedFiles.map(file => ({
+ id: file.name,
+ filename: file.name,
+ file_type: file.type,
+ file_size: file.size,
+ uploaded_at: new Date().toISOString(),
+ processed: true
+ }))}
+ onRemoveAttachment={(attachmentId) => {
+ setSelectedFiles(prev => prev.filter(f => f.name !== attachmentId))
+ }}
+ showModeSelector={true}
+ enableVoice={enableVoice}
+ isVoiceActive={isVoiceActive}
+ onVoiceToggle={() => setIsVoiceActive(!isVoiceActive)}
+ autocompleteSuggestions={autocompleteSuggestions}
+ showAutocomplete={showAutocomplete}
+ onAutocompleteSelect={(suggestion) => {
+ setInput(suggestion)
+ setShowAutocomplete(false)
+ }}
+ />
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/chat/AmaniInput.tsx b/src/components/chat/AmaniInput.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0a3b1c4f84bda7b2a231b40bb575878d6fae4877
--- /dev/null
+++ b/src/components/chat/AmaniInput.tsx
@@ -0,0 +1,368 @@
+"use client"
+
+import { useState, useRef, useEffect, KeyboardEvent } from "react"
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { Badge } from "@/components/ui/badge"
+import {
+ Send,
+ Paperclip,
+ Mic,
+ MicOff,
+ ChevronDown,
+ ChevronUp,
+ Sparkles,
+ Search,
+ Bot,
+ BookOpen,
+ X
+} from "lucide-react"
+import type { Attachment } from "./types"
+
+interface AmaniInputProps {
+ value: string
+ onChange: (value: string) => void
+ onSend: () => void
+ onFileSelect?: (files: File[]) => void
+ onModeChange?: (mode: "chat" | "hybrid" | "research") => void
+ placeholder?: string
+ disabled?: boolean
+ isLoading?: boolean
+ mode?: "chat" | "hybrid" | "research"
+ attachments?: Attachment[]
+ onRemoveAttachment?: (attachmentId: string) => void
+ showModeSelector?: boolean
+ enableVoice?: boolean
+ isVoiceActive?: boolean
+ onVoiceToggle?: () => void
+ autocompleteSuggestions?: string[]
+ showAutocomplete?: boolean
+ onAutocompleteSelect?: (suggestion: string) => void
+}
+
+interface ModeOption {
+ id: "chat" | "hybrid" | "research"
+ label: string
+ description: string
+ icon: React.ReactNode
+ color: string
+}
+
+const modeOptions: ModeOption[] = [
+ {
+ id: "chat",
+ label: "Chat",
+ description: "General conversation",
+ icon: ,
+ color: "text-blue-600"
+ },
+ {
+ id: "hybrid",
+ label: "Hybrid RAG",
+ description: "Enhanced with knowledge base",
+ icon: ,
+ color: "text-purple-600"
+ },
+ {
+ id: "research",
+ label: "Research",
+ description: "Deep research mode",
+ icon: ,
+ color: "text-green-600"
+ }
+]
+
+interface AttachmentPreviewProps {
+ attachment: Attachment
+ onRemove: () => void
+}
+
+function AttachmentPreview({ attachment, onRemove }: AttachmentPreviewProps) {
+ const getFileIcon = () => {
+ switch (attachment.file_type) {
+ case "image":
+ return 🖼️
+ case "pdf":
+ return 📄
+ case "audio":
+ return 🎵
+ case "video":
+ return 🎥
+ default:
+ return 📎
+ }
+ }
+
+ const formatFileSize = (bytes: number) => {
+ if (bytes === 0) return "0 Bytes"
+ const k = 1024
+ const sizes = ["Bytes", "KB", "MB", "GB"]
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
+ }
+
+ return (
+
+ {getFileIcon()}
+
+
{attachment.filename}
+
{formatFileSize(attachment.file_size)}
+
+
+
+
+
+ )
+}
+
+interface AutocompleteDropdownProps {
+ suggestions: string[]
+ onSelect: (suggestion: string) => void
+ onClose: () => void
+}
+
+function AutocompleteDropdown({ suggestions, onSelect, onClose }: AutocompleteDropdownProps) {
+ return (
+
+ {suggestions.map((suggestion, index) => (
+ {
+ onSelect(suggestion)
+ onClose()
+ }}
+ >
+ {suggestion}
+
+ ))}
+
+ )
+}
+
+export function AmaniInput({
+ value,
+ onChange,
+ onSend,
+ onFileSelect,
+ onModeChange,
+ placeholder = "Ask anything...",
+ disabled = false,
+ isLoading = false,
+ mode = "chat",
+ attachments = [],
+ onRemoveAttachment,
+ showModeSelector = true,
+ enableVoice = false,
+ isVoiceActive = false,
+ onVoiceToggle,
+ autocompleteSuggestions = [],
+ showAutocomplete = false,
+ onAutocompleteSelect
+}: AmaniInputProps) {
+ const [isExpanded, setIsExpanded] = useState(false)
+ const [showModeMenu, setShowModeMenu] = useState(false)
+ const textareaRef = useRef(null)
+ const containerRef = useRef(null)
+
+ useEffect(() => {
+ if (textareaRef.current) {
+ textareaRef.current.style.height = "auto"
+ textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`
+ }
+ }, [value])
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault()
+ if (!isLoading && value.trim()) {
+ onSend()
+ }
+ }
+ }
+
+ const handleFileSelect = (e: React.ChangeEvent) => {
+ const files = Array.from(e.target.files || [])
+ if (files.length > 0) {
+ onFileSelect?.(files)
+ }
+ e.target.value = ""
+ }
+
+ const currentMode = modeOptions.find(m => m.id === mode) || modeOptions[0]
+
+ return (
+
+ {/* Autocomplete Dropdown */}
+ {showAutocomplete && autocompleteSuggestions.length > 0 && (
+
{})}
+ onClose={() => {}}
+ />
+ )}
+
+ {/* Attachments */}
+ {attachments.length > 0 && (
+
+ {attachments.map((attachment) => (
+
onRemoveAttachment?.(attachment.id)}
+ />
+ ))}
+
+ )}
+
+ {/* Main Input */}
+
+ {/* Mode Selector */}
+ {showModeSelector && onModeChange && (
+
+
setShowModeMenu(!showModeMenu)}
+ >
+ {currentMode.icon}
+ {currentMode.label}
+ {showModeMenu ? (
+
+ ) : (
+
+ )}
+
+
+ {showModeMenu && (
+
+ {modeOptions.map((option) => (
+
{
+ onModeChange(option.id)
+ setShowModeMenu(false)
+ }}
+ >
+ {option.icon}
+
+
{option.label}
+
{option.description}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* Textarea */}
+
+
+
+ {/* Action Buttons */}
+
+ {/* File Upload */}
+ {onFileSelect && (
+ <>
+
+
document.getElementById("file-upload")?.click()}
+ disabled={disabled || isLoading}
+ >
+
+
+ >
+ )}
+
+ {/* Voice Toggle */}
+ {enableVoice && onVoiceToggle && (
+
+ {isVoiceActive ? : }
+
+ )}
+
+ {/* Send Button */}
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* Expanded Options */}
+ {isExpanded && (
+
+
+ Advanced options
+ setIsExpanded(false)}
+ >
+
+
+
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/chat/AmaniMessage.tsx b/src/components/chat/AmaniMessage.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..aa5c9a608118a6eb83cd40a17ce4072c20108b6d
--- /dev/null
+++ b/src/components/chat/AmaniMessage.tsx
@@ -0,0 +1,425 @@
+"use client"
+
+import { useState, useRef, useEffect } from "react"
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import {
+ Bot,
+ User,
+ Copy,
+ RotateCw,
+ ThumbsUp,
+ ThumbsDown,
+ MoreVertical,
+ ExternalLink,
+ Check,
+ Pencil,
+ AlertTriangle,
+ RefreshCw
+} from "lucide-react"
+import { Textarea } from "@/components/ui/textarea"
+import ReactMarkdown from "react-markdown"
+import remarkGfm from "remark-gfm"
+import rehypeHighlight from "rehype-highlight"
+import type { Message, Source } from "./types"
+
+interface AmaniMessageProps {
+ message: Message
+ onCopy: (content: string) => void
+ onRegenerate: (messageId: string) => void
+ onFeedback: (messageId: string, type: "like" | "dislike") => void
+ isLoading?: boolean
+ showFeedback?: boolean
+ isEditing?: boolean
+ editingContent?: string
+ onEditChange?: (content: string) => void
+ onSaveEdit?: (messageId: string) => void
+ onCancelEdit?: () => void
+ onStartEdit?: (message: Message) => void
+ onCopyFailed?: (message: Message) => void
+ onEditFailed?: (message: Message) => void
+ onResendFailed?: (message: Message) => void
+}
+
+interface InlineCitationProps {
+ source: Source
+ index: number
+ onHover: (source: Source | null) => void
+}
+
+function InlineCitation({ source, index, onHover }: InlineCitationProps) {
+ return (
+ onHover(source)}
+ onMouseLeave={() => onHover(null)}
+ onClick={() => window.open(source.url, '_blank')}
+ >
+ {index}
+
+ )
+}
+
+interface CitationTooltipProps {
+ source: Source | null
+ position: { x: number; y: number }
+}
+
+function CitationTooltip({ source, position }: CitationTooltipProps) {
+ if (!source) return null
+
+ return (
+
+
+
+ {source.source_name}
+
+
+
{source.title}
+
{source.excerpt}
+
+
+ )
+}
+
+function processContentWithCitations(content: string, sources?: Source[]) {
+ if (!sources || sources.length === 0) return content
+
+ let processedContent = content
+ sources.forEach((source, index) => {
+ const citationPattern = new RegExp(`\\[${index + 1}\\]`, 'g')
+ processedContent = processedContent.replace(citationPattern, `{{CITATION:${index}}}`)
+ })
+
+ return processedContent
+}
+
+export function AmaniMessage({
+ message,
+ onCopy,
+ onRegenerate,
+ onFeedback,
+ isLoading = false,
+ showFeedback = true,
+ isEditing = false,
+ editingContent = "",
+ onEditChange,
+ onSaveEdit,
+ onCancelEdit,
+ onStartEdit,
+ onCopyFailed,
+ onEditFailed,
+ onResendFailed
+}: AmaniMessageProps) {
+ const [hoveredSource, setHoveredSource] = useState(null)
+ const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 })
+ const [hasCopied, setHasCopied] = useState(false)
+ const [showActions, setShowActions] = useState(false)
+ const messageRef = useRef(null)
+
+ const isUser = message.role === "user"
+ const hasFeedback = message.feedback_type !== undefined
+
+ const handleCopy = async () => {
+ await onCopy(message.content)
+ setHasCopied(true)
+ setTimeout(() => setHasCopied(false), 2000)
+ }
+
+ const handleSourceHover = (source: Source | null, event?: React.MouseEvent) => {
+ setHoveredSource(source)
+ if (source && event) {
+ const rect = event.currentTarget.getBoundingClientRect()
+ setTooltipPosition({
+ x: rect.right + 10,
+ y: rect.top
+ })
+ }
+ }
+
+ const renderContent = () => {
+ const processedContent = processContentWithCitations(message.content, message.sources)
+ const parts = processedContent.split(/(\{\{CITATION:\d+\}\})/g)
+
+ return (
+
+ {/* Failed Message State */}
+ {message.failed && (
+
+
+
+
Failed to send message
+
+
+ {onResendFailed && (
+
onResendFailed(message)}
+ >
+
+ Retry
+
+ )}
+ {onEditFailed && (
+
onEditFailed(message)}
+ >
+
+ Edit
+
+ )}
+ {onCopyFailed && (
+
onCopyFailed(message)}
+ >
+
+ Copy
+
+ )}
+
+
+ )}
+
+ {!message.failed && (
+ <>
+
{children}
,
+ h1: ({ children }) => {children} ,
+ h2: ({ children }) => {children} ,
+ h3: ({ children }) => {children} ,
+ ul: ({ children }) => ,
+ ol: ({ children }) => {children} ,
+ li: ({ children }) => {children} ,
+ blockquote: ({ children }) => (
+
+ {children}
+
+ ),
+ code: ({ children, className, ...props }) => {
+ // Check if this is inline code by looking at the className
+ // Code blocks have a language class (e.g., "language-js"), inline code doesn't
+ const isInline = !className || !className.startsWith("language-")
+ return isInline ? (
+
+ {children}
+
+ ) : (
+
+ {children}
+
+ )
+ },
+ }}
+ >
+ {processedContent.replace(/\{\{CITATION:(\d+)\}\}/g, (match, index) => {
+ const source = message.sources?.[parseInt(index)]
+ return source ? `[${parseInt(index) + 1}]` : match
+ })}
+
+
+ {message.sources && message.sources.length > 0 && (
+
+ {message.sources.map((source, index) => (
+ handleSourceHover(source)}
+ />
+ ))}
+
+ )}
+ >
+ )}
+
+ )
+}
+
+ return (
+ setShowActions(true)}
+ onMouseLeave={() => {
+ setShowActions(false)
+ setHoveredSource(null)
+ }}
+ >
+ {/* Avatar */}
+
+ {isUser ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Message Content */}
+
+
+ {isEditing ? (
+
+ ) : (
+ renderContent()
+ )}
+
+
+ {/* Message Actions */}
+ {showActions && !isEditing && (
+
+
+ {!isUser && (
+ <>
+
+ {hasCopied ? (
+
+ ) : (
+
+ )}
+
+
+
onRegenerate(message.id)}
+ >
+
+
+
+ {showFeedback && (
+ <>
+
onFeedback(message.id, "like")}
+ >
+
+
+
+
onFeedback(message.id, "dislike")}
+ >
+
+
+ >
+ )}
+
+
+
+
+ >
+ )}
+
+ {isUser && onStartEdit && (
+
+
onStartEdit(message)}
+ >
+
+
+
+ {hasCopied ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+ )}
+
+
+ {/* Citation Tooltip */}
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/chat/AmaniMessageList.tsx b/src/components/chat/AmaniMessageList.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..8e49fadf0ce0303580d092c911d0a6aa63814354
--- /dev/null
+++ b/src/components/chat/AmaniMessageList.tsx
@@ -0,0 +1,369 @@
+"use client"
+
+import { useState, useRef, useEffect } from "react"
+import { cn } from "@/lib/utils"
+import { AmaniMessage } from "./AmaniMessage"
+import { MessageActions } from "./MessageActions"
+import { ThinkingIndicator, CompactThinkingIndicator } from "./ThinkingIndicator"
+import { SourcePanel, SourceSummary } from "./SourcePanel"
+import { WelcomeScreen } from "./WelcomeScreen"
+import { Loader2 } from "lucide-react"
+import { ShareSheet } from "./ShareSheet"
+import type { Message, ShareSheetState, SharePlatform } from "./types"
+
+interface AmaniMessageListProps {
+ messages: Message[]
+ isLoading: boolean
+ isThinking?: boolean
+ thinkingSteps?: any[]
+ onSendMessage: (content: string) => void
+ onRegenerate: (messageId: string) => void
+ onFeedback: (messageId: string, type: "like" | "dislike") => void
+ onCopy: (content: string) => void
+ onShare: (message: Message) => void
+ onGeneratePDF?: (messageId: string) => void
+ onGenerateWord?: (messageId: string) => void
+ showWelcomeScreen?: boolean
+ className?: string
+ showInlineSources?: boolean
+ enableThinkingIndicator?: boolean
+ messageClassName?: string
+ shareSheet?: ShareSheetState | null
+ onCloseShareSheet?: () => void
+ onChangeSharePlatform?: (message: Message, platform: SharePlatform) => void
+ onCopyShareContent?: () => void
+ onGenerateShareImage?: (message: Message) => void
+ onOpenShareIntent?: (message: Message) => void
+ onAuthenticatePlatform?: (platform: SharePlatform) => void
+ onPostDirectly?: (message: Message) => void
+ platformTokens?: Record
+ editingMessageId?: string | null
+ editingContent?: string
+ setEditingContent?: (content: string) => void
+ onSaveEdit?: (messageId: string) => void
+ onCancelEdit?: () => void
+ onStartEdit?: (message: Message) => void
+ onCopyFailedQuery?: (message: Message) => void
+ onEditFailedQuery?: (message: Message) => void
+ onResendFailedQuery?: (message: Message) => void
+}
+
+interface MessageGroup {
+ id: string
+ messages: Message[]
+ hasAssistantResponse: boolean
+ isComplete: boolean
+}
+
+export function AmaniMessageList({
+ messages,
+ isLoading,
+ isThinking = false,
+ thinkingSteps = [],
+ onSendMessage,
+ onRegenerate,
+ onFeedback,
+ onCopy,
+ onShare,
+ onGeneratePDF,
+ onGenerateWord,
+ showWelcomeScreen = false,
+ className,
+ showInlineSources = true,
+ enableThinkingIndicator = true,
+ messageClassName,
+ shareSheet,
+ onCloseShareSheet,
+ onChangeSharePlatform,
+ onCopyShareContent,
+ onGenerateShareImage,
+ onOpenShareIntent,
+ onAuthenticatePlatform,
+ onPostDirectly,
+ platformTokens,
+ editingMessageId,
+ editingContent,
+ setEditingContent,
+ onSaveEdit,
+ onCancelEdit,
+ onStartEdit,
+ onCopyFailedQuery,
+ onEditFailedQuery,
+ onResendFailedQuery
+}: AmaniMessageListProps) {
+ const [expandedSources, setExpandedSources] = useState>({})
+ const [expandedThinking, setExpandedThinking] = useState>({})
+ const messagesEndRef = useRef(null)
+ const containerRef = useRef(null)
+
+ // Auto-scroll to bottom when new messages arrive
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
+ }, [messages, isLoading])
+
+ // Group messages into conversation turns
+ const messageGroups: MessageGroup[] = []
+ let currentGroup: MessageGroup | null = null
+
+ messages.forEach((message, index) => {
+ if (message.role === "user") {
+ // Start new group for user message
+ if (currentGroup) {
+ messageGroups.push(currentGroup)
+ }
+ currentGroup = {
+ id: message.id,
+ messages: [message],
+ hasAssistantResponse: false,
+ isComplete: false
+ }
+ } else if (message.role === "assistant" && currentGroup) {
+ // Add assistant message to current group
+ currentGroup.messages.push(message)
+ currentGroup.hasAssistantResponse = true
+ currentGroup.isComplete = !message.isRegenerating && !isLoading
+ }
+ })
+
+ // Add the last group if it exists
+ if (currentGroup) {
+ messageGroups.push(currentGroup)
+ }
+
+ const toggleSources = (groupId: string) => {
+ setExpandedSources(prev => ({
+ ...prev,
+ [groupId]: !prev[groupId]
+ }))
+ }
+
+ const toggleThinking = (groupId: string) => {
+ setExpandedThinking(prev => ({
+ ...prev,
+ [groupId]: !prev[groupId]
+ }))
+ }
+
+ if (showWelcomeScreen && messages.length === 0) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+ {messageGroups.map((group) => {
+ const userMessage = group.messages.find(m => m.role === "user")
+ const assistantMessage = group.messages.find(m => m.role === "assistant")
+ const showThinking = enableThinkingIndicator &&
+ group.hasAssistantResponse &&
+ assistantMessage?.isRegenerating
+
+ return (
+
+ {/* User Message */}
+ {userMessage && (
+
+
+ {shareSheet && shareSheet.messageId === userMessage.id && (
+
+ )}
+
+ )}
+
+ {/* Assistant Response Area */}
+ {group.hasAssistantResponse && assistantMessage && (
+
+ {/* Thinking Indicator */}
+ {showThinking && (
+
+ toggleThinking(group.id)}
+ className="mb-3"
+ />
+
+ )}
+
+ {/* Assistant Message */}
+
+
+
+ {/* Message Actions */}
+
+
+
+
+
+ {/* Inline Sources */}
+ {showInlineSources && assistantMessage.sources && assistantMessage.sources.length > 0 && (
+
+ toggleSources(group.id)}
+ variant="inline"
+ />
+
+ )}
+
+ {/* Source Summary */}
+ {!showInlineSources && assistantMessage.sources && assistantMessage.sources.length > 0 && (
+
+
+
+ )}
+
+ {/* Share Sheet */}
+ {shareSheet && shareSheet.messageId === assistantMessage.id && (
+
+
+
+ )}
+
+ )}
+
+ {/* Loading State for Current Response */}
+ {!group.hasAssistantResponse && isLoading && group === messageGroups[messageGroups.length - 1] && (
+
+ {enableThinkingIndicator && (
+
+ )}
+
+
+ )}
+
+ )
+ })}
+
+ {/* Global Loading State */}
+ {isLoading && messages.length === 0 && (
+
+
+
+ AmaniQuery is thinking...
+
+
+ )}
+
+
+
+
+ )
+}
+
+interface StreamingMessageProps {
+ content: string
+ isThinking?: boolean
+ sources?: any[]
+ className?: string
+}
+
+export function StreamingMessage({
+ content,
+ isThinking = false,
+ sources = [],
+ className
+}: StreamingMessageProps) {
+ return (
+
+ {isThinking && (
+
+ )}
+
+
+
+
+
Thinking...'
+ }}
+ />
+
+ {sources.length > 0 && (
+
+ )}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/chat/AudioPreview.tsx b/src/components/chat/AudioPreview.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..794f1c0bfa1e7a20bfa44e7789b4a2f2cc3985a1
--- /dev/null
+++ b/src/components/chat/AudioPreview.tsx
@@ -0,0 +1,237 @@
+"use client"
+
+import { useState, useRef, useEffect } from "react"
+import { Button } from "@/components/ui/button"
+import {
+ Play,
+ Pause,
+ Volume2,
+ VolumeX,
+ Loader2,
+ RefreshCw,
+ AlertCircle,
+ FileAudio
+} from "lucide-react"
+import { Slider } from "@/components/ui/slider"
+import { formatDuration } from "./media-api"
+
+interface AudioPreviewProps {
+ src: string
+ filename?: string
+ transcript?: string
+ className?: string
+ showTranscript?: boolean
+}
+
+export function AudioPreview({
+ src,
+ filename,
+ transcript,
+ className = "",
+ showTranscript = true
+}: AudioPreviewProps) {
+ const audioRef = useRef
(null)
+ const [isPlaying, setIsPlaying] = useState(false)
+ const [isMuted, setIsMuted] = useState(false)
+ const [currentTime, setCurrentTime] = useState(0)
+ const [duration, setDuration] = useState(0)
+ const [isLoading, setIsLoading] = useState(true)
+ const [error, setError] = useState(false)
+ const [volume, setVolume] = useState(1)
+ const [showVolumeSlider, setShowVolumeSlider] = useState(false)
+
+ useEffect(() => {
+ const audio = audioRef.current
+ if (!audio) return
+
+ const handleLoadedMetadata = () => {
+ setDuration(audio.duration)
+ setIsLoading(false)
+ }
+
+ const handleTimeUpdate = () => {
+ setCurrentTime(audio.currentTime)
+ }
+
+ const handleEnded = () => {
+ setIsPlaying(false)
+ setCurrentTime(0)
+ }
+
+ const handleError = () => {
+ setError(true)
+ setIsLoading(false)
+ }
+
+ const handleCanPlay = () => {
+ setIsLoading(false)
+ }
+
+ audio.addEventListener("loadedmetadata", handleLoadedMetadata)
+ audio.addEventListener("timeupdate", handleTimeUpdate)
+ audio.addEventListener("ended", handleEnded)
+ audio.addEventListener("error", handleError)
+ audio.addEventListener("canplay", handleCanPlay)
+
+ return () => {
+ audio.removeEventListener("loadedmetadata", handleLoadedMetadata)
+ audio.removeEventListener("timeupdate", handleTimeUpdate)
+ audio.removeEventListener("ended", handleEnded)
+ audio.removeEventListener("error", handleError)
+ audio.removeEventListener("canplay", handleCanPlay)
+ }
+ }, [src])
+
+ const togglePlay = () => {
+ const audio = audioRef.current
+ if (!audio) return
+
+ if (isPlaying) {
+ audio.pause()
+ } else {
+ audio.play()
+ }
+ setIsPlaying(!isPlaying)
+ }
+
+ const toggleMute = () => {
+ const audio = audioRef.current
+ if (!audio) return
+
+ audio.muted = !isMuted
+ setIsMuted(!isMuted)
+ }
+
+ const handleSeek = (value: number[]) => {
+ const audio = audioRef.current
+ if (!audio) return
+
+ audio.currentTime = value[0]
+ setCurrentTime(value[0])
+ }
+
+ const handleVolumeChange = (value: number[]) => {
+ const audio = audioRef.current
+ if (!audio) return
+
+ const newVolume = value[0]
+ audio.volume = newVolume
+ setVolume(newVolume)
+ setIsMuted(newVolume === 0)
+ }
+
+ const handleRetry = () => {
+ setError(false)
+ setIsLoading(true)
+ const audio = audioRef.current
+ if (audio) {
+ audio.load()
+ }
+ }
+
+ if (error) {
+ return (
+
+
+
Failed to load audio
+
+
+ Retry
+
+
+ )
+ }
+
+ return (
+
+
+
+ {/* Header with filename */}
+ {filename && (
+
+
+ {filename}
+
+ )}
+
+ {/* Player controls */}
+
+ {/* Play/Pause button */}
+
+ {isLoading ? (
+
+ ) : isPlaying ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Progress bar */}
+
+
+
+ {formatDuration(currentTime)}
+ {formatDuration(duration)}
+
+
+
+ {/* Volume control */}
+
setShowVolumeSlider(true)}
+ onMouseLeave={() => setShowVolumeSlider(false)}
+ >
+
+ {isMuted || volume === 0 ? (
+
+ ) : (
+
+ )}
+
+
+ {showVolumeSlider && (
+
+
+
+ )}
+
+
+
+ {/* Transcript section */}
+ {showTranscript && transcript && (
+
+
Transcript:
+
+ “{transcript}”
+
+
+ )}
+
+ )
+}
diff --git a/src/components/chat/ChatHeader.tsx b/src/components/chat/ChatHeader.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3a95d2499477093892a409d9b14f9563e9ab5ccc
--- /dev/null
+++ b/src/components/chat/ChatHeader.tsx
@@ -0,0 +1,84 @@
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { History, Search, Sparkles, Share2, Home } from "lucide-react"
+import Link from "next/link"
+import type { LucideIcon } from "lucide-react"
+
+interface ChatHeaderProps {
+ isResearchMode: boolean
+ useHybrid: boolean
+ currentSessionId: string | null
+ showHistory: boolean
+ isLoading: boolean
+ onToggleHistory: () => void
+ onToggleSidebar?: () => void
+ onShare: () => void
+ HistoryIcon?: LucideIcon
+}
+
+export function ChatHeader({
+ isResearchMode,
+ useHybrid,
+ currentSessionId,
+ showHistory,
+ onToggleHistory,
+ onToggleSidebar,
+ onShare,
+ HistoryIcon
+}: ChatHeaderProps) {
+ return (
+
+
+
+
+
+
AmaniQuery
+
Conversational Legal Intelligence
+
+ {isResearchMode && (
+
+
+ Research
+
+ )}
+ {useHybrid && !isResearchMode && (
+
+
+ Hybrid
+
+ )}
+
+
+ {
+ if (onToggleSidebar && window.innerWidth < 768) {
+ onToggleSidebar()
+ } else {
+ onToggleHistory()
+ }
+ }}
+ >
+ {HistoryIcon ? : }
+ History
+
+ {currentSessionId && (
+
+
+ Share
+
+ )}
+
+
+
+ Home
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..1fcb9c58cf2d6521916eef404683cf85a43d6030
--- /dev/null
+++ b/src/components/chat/ChatInput.tsx
@@ -0,0 +1,230 @@
+import { useState, useRef, useEffect } from "react"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { Badge } from "@/components/ui/badge"
+import { Paperclip, Mic, Send, Loader2, Search, Sparkles } from "lucide-react"
+import { FileUpload } from "./FileUpload"
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { ChevronDown, MessageSquare } from "lucide-react"
+
+interface ChatInputProps {
+ input: string
+ setInput: (value: string) => void
+ isLoading: boolean
+ isResearchMode: boolean
+ useHybrid: boolean
+ selectedFiles: File[]
+ setSelectedFiles: React.Dispatch>
+ onSendMessage: (content: string) => void
+ enableAutocomplete?: boolean
+ autocompleteSuggestions?: string[]
+ showAutocomplete?: boolean
+ setShowAutocomplete?: (show: boolean) => void
+ onToggleResearch: () => void
+ onToggleHybrid: () => void
+}
+
+export function ChatInput({
+ input,
+ setInput,
+ isLoading,
+ isResearchMode,
+ useHybrid,
+ selectedFiles,
+ setSelectedFiles,
+ onSendMessage,
+ enableAutocomplete = true,
+ autocompleteSuggestions = [],
+ showAutocomplete = false,
+ setShowAutocomplete,
+ onToggleResearch,
+ onToggleHybrid
+}: ChatInputProps) {
+ const inputRef = useRef(null)
+ const textareaRef = useRef(null)
+
+ // Auto-resize textarea
+ useEffect(() => {
+ if (textareaRef.current) {
+ textareaRef.current.style.height = "auto"
+ textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`
+ }
+ }, [input])
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault()
+ if (input.trim() || selectedFiles.length > 0) {
+ onSendMessage(input)
+ }
+ }
+
+ const getCurrentMode = () => {
+ if (isResearchMode) return { label: "Research", icon: Search, color: "text-blue-500" }
+ if (useHybrid) return { label: "Hybrid", icon: Sparkles, color: "text-purple-500" }
+ return { label: "Chat", icon: MessageSquare, color: "text-muted-foreground" }
+ }
+
+ const currentMode = getCurrentMode()
+ const ModeIcon = currentMode.icon
+
+ return (
+
+ )
+}
diff --git a/src/components/chat/ChatSidebar.tsx b/src/components/chat/ChatSidebar.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4937c6c4d8b5bd29f06f15a61241f1aa708d8ede
--- /dev/null
+++ b/src/components/chat/ChatSidebar.tsx
@@ -0,0 +1,238 @@
+"use client"
+
+import { useState, useRef, useEffect } from "react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Plus, MessageSquare, Trash2, X, Pencil, Check, X as XIcon } from "lucide-react"
+import type { ChatSession } from "./types"
+
+interface ChatSidebarProps {
+ chatHistory: ChatSession[]
+ currentSessionId: string | null
+ showHistory: boolean
+ onLoadSession: (sessionId: string) => void
+ onDeleteSession: (sessionId: string) => void
+ onRenameSession: (sessionId: string, newTitle: string) => void
+ onCreateSession: () => void
+ onCloseHistory: () => void
+}
+
+export function ChatSidebar({
+ chatHistory,
+ currentSessionId,
+ showHistory,
+ onLoadSession,
+ onDeleteSession,
+ onRenameSession,
+ onCreateSession,
+ onCloseHistory
+}: ChatSidebarProps) {
+ const [editingSessionId, setEditingSessionId] = useState(null)
+ const [editTitle, setEditTitle] = useState("")
+ const editInputRef = useRef(null)
+
+ useEffect(() => {
+ if (editingSessionId && editInputRef.current) {
+ editInputRef.current.focus()
+ }
+ }, [editingSessionId])
+
+ const startEditing = (session: ChatSession, e: React.MouseEvent) => {
+ e.stopPropagation()
+ setEditingSessionId(session.id)
+ setEditTitle(session.title || "")
+ }
+
+ const cancelEditing = (e?: React.MouseEvent) => {
+ e?.stopPropagation()
+ setEditingSessionId(null)
+ setEditTitle("")
+ }
+
+ const saveTitle = (sessionId: string, e?: React.MouseEvent) => {
+ e?.stopPropagation()
+ if (editTitle.trim()) {
+ onRenameSession(sessionId, editTitle.trim())
+ }
+ setEditingSessionId(null)
+ setEditTitle("")
+ }
+
+ const handleKeyDown = (e: React.KeyboardEvent, sessionId: string) => {
+ if (e.key === "Enter") {
+ saveTitle(sessionId)
+ } else if (e.key === "Escape") {
+ cancelEditing()
+ }
+ }
+ // Prevent body scroll when mobile sidebar is open
+ useEffect(() => {
+ if (showHistory) {
+ document.body.style.overflow = "hidden"
+ } else {
+ document.body.style.overflow = "unset"
+ }
+ return () => {
+ document.body.style.overflow = "unset"
+ }
+ }, [showHistory])
+
+ return (
+ <>
+ {/* Desktop Sidebar */}
+
+
+
+
+
Sessions
+
Chat History
+
+
+
+
+
+
+
+ {chatHistory.map((session) => (
+
+
onLoadSession(session.id)}
+ >
+
+
+
+
+
+ {editingSessionId === session.id ? (
+
e.stopPropagation()}>
+ setEditTitle(e.target.value)}
+ onKeyDown={(e) => handleKeyDown(e, session.id)}
+ className="h-6 text-xs py-0 px-1 bg-background/50"
+ />
+ saveTitle(session.id, e)}
+ >
+
+
+
+
+
+
+ ) : (
+ <>
+
+ {session.title || `Chat ${session.id.slice(-6)}`}
+
+
{session.message_count} messages
+ >
+ )}
+
+
+
+
+
startEditing(session, e)}
+ >
+
+
+
{
+ e.stopPropagation()
+ onDeleteSession(session.id)
+ }}
+ >
+
+
+
+
+ ))}
+
+
+
+ {/* Mobile Sidebar Overlay */}
+ {showHistory && (
+
+
e.stopPropagation()}>
+
+
+
Chat History
+
+
+
+
+
{
+ onCreateSession()
+ onCloseHistory()
+ }}
+ >
+
+ New Chat
+
+
+
+ {chatHistory.map((session) => (
+
+
{
+ onLoadSession(session.id)
+ onCloseHistory()
+ }}
+ >
+
+
+
+ {session.title || `Chat ${session.id.slice(-8)}`}
+
+
{session.message_count} messages
+
+
+
{
+ e.stopPropagation()
+ onDeleteSession(session.id)
+ }}
+ >
+
+
+
+ ))}
+
+
+
+ )}
+ >
+ )
+}
diff --git a/src/components/chat/FileUpload.tsx b/src/components/chat/FileUpload.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..497b9cad06c9a1c8d0630516d65506d025375ae9
--- /dev/null
+++ b/src/components/chat/FileUpload.tsx
@@ -0,0 +1,337 @@
+"use client"
+
+import React, { useRef, useState } from "react"
+import { Button } from "@/components/ui/button"
+import { X, FileText, Image, File, Upload, Plus, Music, Video } from "lucide-react"
+
+interface FileUploadProps {
+ files: File[]
+ onFilesChange: (files: File[]) => void
+ maxFiles?: number
+ maxSizeMB?: number
+ enableMedia?: boolean // Enable audio/video support
+ enableDocuments?: boolean // Enable large document support
+}
+
+const ALLOWED_TYPES = {
+ // Documents
+ "application/pdf": [".pdf"],
+ "text/plain": [".txt"],
+ "text/markdown": [".md"],
+ // Images
+ "image/png": [".png"],
+ "image/jpeg": [".jpg", ".jpeg"],
+ "image/gif": [".gif"],
+ "image/webp": [".webp"],
+}
+
+// Extended document types for WeKnora knowledge base ingestion
+const DOCUMENT_TYPES = {
+ "application/msword": [".doc"],
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": [".docx"],
+ "application/vnd.ms-excel": [".xls"],
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [".xlsx"],
+ "text/csv": [".csv"],
+ "application/json": [".json"],
+ "application/rtf": [".rtf"],
+ "text/html": [".html", ".htm"],
+}
+
+const MEDIA_TYPES = {
+ // Audio
+ "audio/mpeg": [".mp3"],
+ "audio/wav": [".wav"],
+ "audio/mp4": [".m4a"],
+ "audio/ogg": [".ogg"],
+ "audio/flac": [".flac"],
+ "audio/aac": [".aac"],
+ // Video
+ "video/mp4": [".mp4"],
+ "video/quicktime": [".mov"],
+ "video/x-msvideo": [".avi"],
+ "video/x-matroska": [".mkv"],
+ "video/webm": [".webm"],
+ "video/x-m4v": [".m4v"],
+}
+
+export function FileUpload({
+ files,
+ onFilesChange,
+ maxFiles = 10,
+ maxSizeMB = 50, // Increased for larger documents
+ enableMedia = true,
+ enableDocuments = true, // Enable extended document types by default
+}: FileUploadProps) {
+ const fileInputRef = useRef(null)
+ const [dragActive, setDragActive] = useState(false)
+ const [imagePreviews, setImagePreviews] = useState>(new Map())
+
+ // Combine allowed types based on flags
+ const getAllowedTypes = () => {
+ let types = { ...ALLOWED_TYPES }
+ if (enableDocuments) {
+ types = { ...types, ...DOCUMENT_TYPES }
+ }
+ if (enableMedia) {
+ types = { ...types, ...MEDIA_TYPES }
+ }
+ return types
+ }
+
+ const handleFiles = (fileList: FileList | null) => {
+ if (!fileList) return
+
+ const newFiles: File[] = []
+ const maxSize = maxSizeMB * 1024 * 1024
+ const allowedTypes = getAllowedTypes()
+
+ Array.from(fileList).forEach((file) => {
+ // Check file count
+ if (files.length + newFiles.length >= maxFiles) {
+ return
+ }
+
+ // Check file size
+ if (file.size > maxSize) {
+ alert(`File ${file.name} exceeds ${maxSizeMB}MB limit`)
+ return
+ }
+
+ // Check file type
+ const isValidType = Object.keys(allowedTypes).some((mimeType) => {
+ const extensions = allowedTypes[mimeType as keyof typeof allowedTypes]
+ return extensions.some((ext) => file.name.toLowerCase().endsWith(ext))
+ })
+
+ if (!isValidType) {
+ alert(`File type not supported: ${file.name}`)
+ return
+ }
+
+ newFiles.push(file)
+ })
+
+ if (newFiles.length > 0) {
+ const updatedFiles = [...files, ...newFiles]
+ onFilesChange(updatedFiles)
+
+ // Generate image previews for new image files
+ newFiles.forEach((file) => {
+ if (file.type.startsWith("image/")) {
+ const fileKey = `${file.name}-${file.size}`
+ const reader = new FileReader()
+ reader.onload = (e) => {
+ if (e.target?.result) {
+ setImagePreviews(prev => new Map(prev).set(fileKey, e.target!.result as string))
+ }
+ }
+ reader.readAsDataURL(file)
+ }
+ })
+ }
+ }
+
+ const handleDrag = (e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ if (e.type === "dragenter" || e.type === "dragover") {
+ setDragActive(true)
+ } else if (e.type === "dragleave") {
+ setDragActive(false)
+ }
+ }
+
+ const handleDrop = (e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setDragActive(false)
+
+ if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
+ handleFiles(e.dataTransfer.files)
+ }
+ }
+
+ const removeFile = (index: number) => {
+ const fileToRemove = files[index]
+ const newFiles = files.filter((_, i) => i !== index)
+ onFilesChange(newFiles)
+ // Clean up preview for removed file
+ if (fileToRemove) {
+ const fileKey = `${fileToRemove.name}-${fileToRemove.size}`
+ setImagePreviews(prev => {
+ const newMap = new Map(prev)
+ newMap.delete(fileKey)
+ return newMap
+ })
+ }
+ }
+
+ const getFileIcon = (file: File) => {
+ if (file.type === "application/pdf") {
+ return
+ } else if (file.type.startsWith("image/")) {
+ return
+ } else if (file.type.startsWith("audio/")) {
+ return
+ } else if (file.type.startsWith("video/")) {
+ return
+ } else {
+ return
+ }
+ }
+
+ const formatFileSize = (bytes: number) => {
+ if (bytes < 1024) return bytes + " B"
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"
+ return (bytes / (1024 * 1024)).toFixed(1) + " MB"
+ }
+
+ const isImage = (file: File) => file.type.startsWith("image/")
+ const canAddMore = files.length < maxFiles
+
+ // Build accept string based on enableMedia
+ const getAcceptString = () => {
+ let base = ".pdf,.png,.jpg,.jpeg,.gif,.webp,.txt,.md"
+ if (enableDocuments) {
+ base += ",.doc,.docx,.xls,.xlsx,.csv,.json,.rtf,.html,.htm"
+ }
+ if (enableMedia) {
+ base += ",.mp3,.wav,.m4a,.ogg,.flac,.aac,.mp4,.mov,.avi,.mkv,.webm,.m4v"
+ }
+ return base
+ }
+
+ const getDescriptionText = () => {
+ if (enableDocuments && enableMedia) {
+ return `Documents, Images, Audio, Video (max ${maxSizeMB}MB, up to ${maxFiles} files)`
+ } else if (enableDocuments) {
+ return `PDF, DOC, Excel, CSV, Images (max ${maxSizeMB}MB, up to ${maxFiles} files)`
+ } else if (enableMedia) {
+ return `PDF, Images, Audio, Video (max ${maxSizeMB}MB, up to ${maxFiles} files)`
+ }
+ return `PDF, Images, Text files (max ${maxSizeMB}MB, up to ${maxFiles} files)`
+ }
+
+ return (
+
+
handleFiles(e.target.files)}
+ className="hidden"
+ />
+
+ {/* Show upload area only when no files are present */}
+ {files.length === 0 && (
+
+
+
+
+ Drag and drop files here, or{" "}
+ fileInputRef.current?.click()}
+ className="text-primary hover:underline"
+ >
+ browse
+
+
+
+ {getDescriptionText()}
+
+
+
+ )}
+
+ {/* Show compact file list when files are present */}
+ {files.length > 0 && (
+
+ {/* Horizontal scrollable file list */}
+
+ {files.map((file, index) => (
+
+ {/* Image preview or icon */}
+ {isImage(file) && imagePreviews.get(`${file.name}-${file.size}`) ? (
+
+
+
+
+ ) : (
+
+ {getFileIcon(file)}
+
+ )}
+
+ {/* File info */}
+
+
+ {file.name}
+
+
+ {formatFileSize(file.size)}
+
+
+
+ {/* Remove button */}
+
removeFile(index)}
+ className="absolute top-1 right-1 h-6 w-6 md:h-7 md:w-7 p-0 rounded-full bg-black/50 hover:bg-black/70 text-white opacity-0 group-hover:opacity-100 transition-opacity"
+ >
+
+
+
+ ))}
+
+ {/* Add more files button */}
+ {canAddMore && (
+
fileInputRef.current?.click()}
+ className="flex-shrink-0 w-24 md:w-28 h-full min-h-[120px] md:min-h-[140px] border-2 border-dashed border-muted-foreground/25 hover:border-primary/50 rounded-lg flex flex-col items-center justify-center gap-1.5 md:gap-2 transition-colors bg-muted/30 hover:bg-muted/50"
+ >
+
+
+ Add more
+
+
+ )}
+
+
+ {/* File count indicator */}
+
+
+ {files.length} of {maxFiles} files
+
+ {files.length >= maxFiles && (
+ Maximum reached
+ )}
+
+
+ )}
+
+ )
+}
+
diff --git a/src/components/chat/ImagePreview.tsx b/src/components/chat/ImagePreview.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..88f5bc7cb2ce7d0b20620fa5fad23d188dc00944
--- /dev/null
+++ b/src/components/chat/ImagePreview.tsx
@@ -0,0 +1,243 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import Image from "next/image"
+import { X, ZoomIn, Loader2, RefreshCw, AlertCircle } from "lucide-react"
+import { Button } from "@/components/ui/button"
+
+interface ImagePreviewProps {
+ src: string
+ alt: string
+ className?: string
+}
+
+export function ImagePreview({ src, alt, className = "" }: ImagePreviewProps) {
+ const [isOpen, setIsOpen] = useState(false)
+ const [imageError, setImageError] = useState(false)
+ const [isLoading, setIsLoading] = useState(true)
+ const [lightboxLoading, setLightboxLoading] = useState(true)
+ const [lightboxError, setLightboxError] = useState(false)
+ const [retryKey, setRetryKey] = useState(0)
+
+ // Handle ESC key to close modal
+ useEffect(() => {
+ if (!isOpen) return
+
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ setIsOpen(false)
+ }
+ }
+
+ // Prevent body scroll when modal is open
+ document.body.style.overflow = "hidden"
+
+ window.addEventListener("keydown", handleEscape)
+
+ return () => {
+ window.removeEventListener("keydown", handleEscape)
+ document.body.style.overflow = "unset"
+ }
+ }, [isOpen])
+
+ const handleImageLoad = () => {
+ setIsLoading(false)
+ }
+
+ const handleImageError = () => {
+ setImageError(true)
+ setIsLoading(false)
+ }
+
+ const handleLightboxImageLoad = () => {
+ setLightboxLoading(false)
+ }
+
+ const handleLightboxImageError = () => {
+ setLightboxError(true)
+ setLightboxLoading(false)
+ }
+
+ const handleRetry = () => {
+ setImageError(false)
+ setIsLoading(true)
+ setLightboxError(false)
+ setLightboxLoading(true)
+ // Force reload by updating retry key which will change the src
+ setRetryKey(prev => prev + 1)
+ }
+
+ // Add cache-busting parameter for retry
+ const imageSrc = retryKey > 0
+ ? (src.includes("?") ? `${src}&retry=${retryKey}` : `${src}?retry=${retryKey}`)
+ : src
+
+ const handleOpen = () => {
+ setIsOpen(true)
+ setLightboxLoading(true)
+ setLightboxError(false)
+ }
+
+ const handleClose = () => {
+ setIsOpen(false)
+ }
+
+ // Error state for thumbnail
+ if (imageError) {
+ return (
+
+
+
Failed to load image
+
+
+ Retry
+
+
+ )
+ }
+
+ return (
+ <>
+ {/* Thumbnail */}
+
+
+ {isLoading && (
+
+
+
+ )}
+
+ {/* Overlay on hover */}
+
+
+
+
+
+
+ {/* Lightbox Modal */}
+ {isOpen && (
+
+
+ {/* Close button */}
+
+
+
+
+ {/* Full-size image */}
+
e.stopPropagation()}
+ >
+ {lightboxLoading && (
+
+
+
+ )}
+ {lightboxError ? (
+
+
+
Failed to load image
+
+
+ Retry
+
+
+ ) : (
+
+ )}
+
+
+
+ )}
+ >
+ )
+}
+
+interface ImagePreviewGridProps {
+ images: Array<{ src: string; alt: string }>
+ className?: string
+}
+
+export function ImagePreviewGrid({ images, className = "" }: ImagePreviewGridProps) {
+ if (images.length === 0) return null
+
+ if (images.length === 1) {
+ return
+ }
+
+ // Responsive grid layout
+ const getGridClasses = () => {
+ if (images.length === 2) {
+ return "grid-cols-1 sm:grid-cols-2"
+ } else if (images.length === 3) {
+ return "grid-cols-1 sm:grid-cols-2"
+ } else if (images.length === 4) {
+ return "grid-cols-1 sm:grid-cols-2"
+ } else {
+ return "grid-cols-1 sm:grid-cols-2 md:grid-cols-3"
+ }
+ }
+
+ return (
+
+ {images.map((image, index) => {
+ // Special layout for 3 images: first image spans 2 rows on larger screens
+ const rowSpanClass = index === 0 && images.length === 3
+ ? "sm:row-span-2"
+ : ""
+ return (
+
+ )
+ })}
+
+ )
+}
+
diff --git a/src/components/chat/ImpactCalculator.tsx b/src/components/chat/ImpactCalculator.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..315e44fe4880d87fb85c4412eb115f98d3fd9121
--- /dev/null
+++ b/src/components/chat/ImpactCalculator.tsx
@@ -0,0 +1,163 @@
+import React, { useState, useEffect, useMemo } from "react"
+import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import { Button } from "@/components/ui/button"
+import { Calculator, Info, RefreshCw } from "lucide-react"
+import { InteractiveWidget, WidgetInput, WidgetOutput } from "./types"
+
+interface ImpactCalculatorProps {
+ widget: InteractiveWidget
+ className?: string
+}
+
+export function ImpactCalculator({ widget, className }: ImpactCalculatorProps) {
+ // Initialize state with default values or empty strings
+ const [values, setValues] = useState>(() => {
+ const initial: Record = {}
+ widget.inputs.forEach(input => {
+ if (input.default_value) {
+ initial[input.name] = input.default_value
+ }
+ })
+ return initial
+ })
+
+ // Handle input changes
+ const handleInputChange = (name: string, value: string) => {
+ setValues(prev => ({ ...prev, [name]: value }))
+ }
+
+ // Calculate results using useMemo to avoid useEffect setState cycles
+ const { results, error } = useMemo(() => {
+ const initialResults: Record = {}
+
+ // Check if we have values for all required inputs
+ const hasAllInputs = widget.inputs.every(input => values[input.name] !== undefined && values[input.name] !== "")
+
+ if (!hasAllInputs) {
+ return { results: initialResults, error: null }
+ }
+
+ try {
+ // Prepare variables for the formula
+ const variables: Record = {}
+ let allValid = true
+
+ widget.inputs.forEach(input => {
+ const val = parseFloat(values[input.name] || "0")
+ if (isNaN(val)) {
+ allValid = false
+ }
+ variables[input.name] = val
+ })
+
+ if (!allValid) {
+ return { results: initialResults, error: null }
+ }
+
+ // Create a safe evaluation context
+ const argNames = Object.keys(variables)
+ const argValues = Object.values(variables)
+
+ // Wrap formula in a return statement if it doesn't have one
+ let formulaBody = widget.formula
+ if (!formulaBody.includes("return") && !formulaBody.includes(";")) {
+ formulaBody = `return ${formulaBody};`
+ } else if (!formulaBody.includes("return")) {
+ if (!formulaBody.trim().startsWith("let") && !formulaBody.trim().startsWith("const") && !formulaBody.trim().startsWith("var") && !formulaBody.trim().startsWith("if")) {
+ formulaBody = `return ${formulaBody};`
+ }
+ }
+
+ const calculateFn = new Function(...argNames, formulaBody)
+ const resultValue = calculateFn(...argValues)
+
+ // Format outputs
+ widget.outputs.forEach(output => {
+ let formatted = output.format.replace("{value}", resultValue.toLocaleString(undefined, { maximumFractionDigits: 2 }))
+ initialResults[output.label] = formatted
+ })
+
+ return { results: initialResults, error: null }
+ } catch (err) {
+ console.error("Calculation error:", err)
+ return { results: initialResults, error: "Invalid calculation formula or inputs" }
+ }
+ }, [values, widget.formula, widget.inputs, widget.outputs])
+
+ const handleReset = () => {
+ const initial: Record = {}
+ widget.inputs.forEach(input => {
+ if (input.default_value) {
+ initial[input.name] = input.default_value
+ }
+ })
+ setValues(initial)
+ }
+
+ return (
+
+
+
+
+
+
+
+ {widget.title}
+ {widget.description}
+
+
+
+
+
+ {widget.inputs.map((input) => (
+
+
+ {input.label}
+
+ handleInputChange(input.name, e.target.value)}
+ className="bg-background/50 border-primary/10 focus:border-primary/30 transition-colors"
+ />
+
+ ))}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {Object.keys(results).length > 0 && (
+
+ {widget.outputs.map((output, idx) => (
+
+ {output.label}
+ {results[output.label]}
+
+ ))}
+
+ )}
+
+
+
+ {widget.source_citation && (
+ <>
+
+ Source: {widget.source_citation}
+ >
+ )}
+
+
+
+ Reset
+
+
+
+ )
+}
diff --git a/src/components/chat/LegalDiffViewer.tsx b/src/components/chat/LegalDiffViewer.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..86e93329d470547a649a7a3f2dc6dcc125ec9cbf
--- /dev/null
+++ b/src/components/chat/LegalDiffViewer.tsx
@@ -0,0 +1,164 @@
+import React, { useMemo } from "react"
+import ReactDiffViewer, { DiffMethod } from "react-diff-viewer-continued"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { GitCompare, ArrowRight } from "lucide-react"
+import { GithubDiff } from "./types"
+
+interface LegalDiffViewerProps {
+ diff: GithubDiff
+ className?: string
+}
+
+export function LegalDiffViewer({ diff, className }: LegalDiffViewerProps) {
+ // Calculate percentage/number change for the badge
+ const changeBadge = useMemo(() => {
+ try {
+ // Extract numbers from old and new text
+ const oldNumbers = diff.old_text.match(/(\d+(\.\d+)?)/g)?.map(Number) || []
+ const newNumbers = diff.new_text.match(/(\d+(\.\d+)?)/g)?.map(Number) || []
+
+ // Simple heuristic: if we find one number in each that changed
+ if (oldNumbers.length === 1 && newNumbers.length === 1) {
+ const oldVal = oldNumbers[0]
+ const newVal = newNumbers[0]
+
+ if (oldVal !== newVal) {
+ const percentChange = ((newVal - oldVal) / oldVal) * 100
+ const isIncrease = newVal > oldVal
+
+ return {
+ text: `${isIncrease ? "+" : ""}${percentChange.toFixed(1)}% ${isIncrease ? "Increase" : "Decrease"}`,
+ variant: isIncrease ? "destructive" : "default" as "default" | "destructive" | "outline" | "secondary",
+ color: isIncrease ? "bg-red-600" : "bg-green-600" // Higher taxes (increase) usually bad/red, lower good/green? Or just standard diff colors?
+ // Let's stick to standard: Green = Increase, Red = Decrease for numbers usually,
+ // BUT for taxes: Increase = Bad (Red), Decrease = Good (Green).
+ // Let's just use neutral or standard diff colors to avoid bias.
+ // For the badge, let's just show the change.
+ }
+ }
+ }
+
+ // Check for specific keywords like "increased from X to Y"
+ // This is handled by the diff view itself visually, but a badge is nice.
+ return null
+ } catch (e) {
+ return null
+ }
+ }, [diff.old_text, diff.new_text])
+
+ // Custom styles for react-diff-viewer-2 to match Kenyan flag theme
+ // Red (#B91C1C) for deleted, Green (#16A34A) for added
+ const newStyles = {
+ variables: {
+ light: {
+ diffViewerBackground: "transparent",
+ diffViewerColor: "currentColor",
+ addedBackground: "rgba(22, 163, 74, 0.2)", // Green #16A34A
+ addedColor: "currentColor",
+ removedBackground: "rgba(185, 28, 28, 0.2)", // Red #B91C1C
+ removedColor: "currentColor",
+ wordAddedBackground: "rgba(22, 163, 74, 0.4)",
+ wordRemovedBackground: "rgba(185, 28, 28, 0.4)",
+ addedGutterBackground: "rgba(22, 163, 74, 0.1)",
+ removedGutterBackground: "rgba(185, 28, 28, 0.1)",
+ gutterBackground: "transparent",
+ gutterBackgroundDark: "transparent",
+ highlightBackground: "rgba(255, 255, 255, 0.1)",
+ highlightGutterBackground: "rgba(255, 255, 255, 0.1)",
+ codeFoldGutterBackground: "transparent",
+ codeFoldBackground: "transparent",
+ emptyLineBackground: "transparent",
+ gutterColor: "currentColor",
+ addedGutterColor: "currentColor",
+ removedGutterColor: "currentColor",
+ codeFoldContentColor: "currentColor",
+ diffViewerTitleBackground: "transparent",
+ diffViewerTitleColor: "currentColor",
+ diffViewerTitleBorderColor: "transparent",
+ },
+ dark: {
+ diffViewerBackground: "transparent",
+ diffViewerColor: "currentColor",
+ addedBackground: "rgba(22, 163, 74, 0.2)", // Green
+ addedColor: "currentColor",
+ removedBackground: "rgba(185, 28, 28, 0.2)", // Red
+ removedColor: "currentColor",
+ wordAddedBackground: "rgba(22, 163, 74, 0.4)",
+ wordRemovedBackground: "rgba(185, 28, 28, 0.4)",
+ addedGutterBackground: "rgba(22, 163, 74, 0.1)",
+ removedGutterBackground: "rgba(185, 28, 28, 0.1)",
+ gutterBackground: "transparent",
+ gutterBackgroundDark: "transparent",
+ highlightBackground: "rgba(255, 255, 255, 0.1)",
+ highlightGutterBackground: "rgba(255, 255, 255, 0.1)",
+ codeFoldGutterBackground: "transparent",
+ codeFoldBackground: "transparent",
+ emptyLineBackground: "transparent",
+ gutterColor: "currentColor",
+ addedGutterColor: "currentColor",
+ removedGutterColor: "currentColor",
+ codeFoldContentColor: "currentColor",
+ diffViewerTitleBackground: "transparent",
+ diffViewerTitleColor: "currentColor",
+ diffViewerTitleBorderColor: "transparent",
+ }
+ },
+ line: {
+ padding: '10px 2px',
+ '&:hover': {
+ background: 'rgba(255, 255, 255, 0.05)',
+ },
+ },
+ content: {
+ fontFamily: 'inherit',
+ fontSize: '0.9rem',
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {diff.title}
+
+
+
+ {changeBadge && (
+
+ {changeBadge.text}
+
+ )}
+
+
+
+
+ {/* We force dark mode styles if the app is in dark mode, but react-diff-viewer-2
+ uses a 'useDarkTheme' prop. We can try to detect or just pass true/false.
+ For now, let's assume the app handles theme via class 'dark'.
+ Since we can't easily detect system preference here without hooks,
+ we'll rely on the parent passing a theme or default to light/dark based on CSS variables.
+ Actually, react-diff-viewer-2 styles object handles both light and dark keys,
+ but we need to tell it which one to use.
+ */}
+
+
+
+
+ )
+}
diff --git a/src/components/chat/MessageActions.tsx b/src/components/chat/MessageActions.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..31d1b5045c3a3a2957d901f90a3582c78f1508fb
--- /dev/null
+++ b/src/components/chat/MessageActions.tsx
@@ -0,0 +1,270 @@
+"use client"
+
+import { useState, useRef, useEffect } from "react"
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import {
+ Copy,
+ RotateCw,
+ ThumbsUp,
+ ThumbsDown,
+ Share2,
+ Download,
+ Edit,
+ MoreVertical,
+ Check,
+ X,
+ FileText,
+ Image,
+ Link2
+} from "lucide-react"
+import type { Message } from "./types"
+
+interface MessageActionsProps {
+ message: Message
+ onCopy: (content: string) => void
+ onRegenerate: (messageId: string) => void
+ onFeedback: (messageId: string, type: "like" | "dislike") => void
+ onShare: (message: Message) => void
+ onGeneratePDF?: (messageId: string) => void
+ onGenerateWord?: (messageId: string) => void
+ onEdit?: (message: Message) => void
+ className?: string
+ showFeedback?: boolean
+ compact?: boolean
+}
+
+interface ActionButtonProps {
+ icon: React.ReactNode
+ label: string
+ onClick: () => void
+ variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
+ className?: string
+ isActive?: boolean
+}
+
+function ActionButton({ icon, label, onClick, variant = "ghost", className, isActive }: ActionButtonProps) {
+ return (
+
+ {icon}
+ {label}
+
+ )
+}
+
+export function MessageActions({
+ message,
+ onCopy,
+ onRegenerate,
+ onFeedback,
+ onShare,
+ onGeneratePDF,
+ onGenerateWord,
+ onEdit,
+ className,
+ showFeedback = true,
+ compact = false
+}: MessageActionsProps) {
+ const [hasCopied, setHasCopied] = useState(false)
+ const [showMenu, setShowMenu] = useState(false)
+ const menuRef = useRef(null)
+
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
+ setShowMenu(false)
+ }
+ }
+
+ document.addEventListener("mousedown", handleClickOutside)
+ return () => document.removeEventListener("mousedown", handleClickOutside)
+ }, [])
+
+ const handleCopy = async () => {
+ await onCopy(message.content)
+ setHasCopied(true)
+ setTimeout(() => setHasCopied(false), 2000)
+ setShowMenu(false)
+ }
+
+ const handleRegenerate = () => {
+ onRegenerate(message.id)
+ setShowMenu(false)
+ }
+
+ const handleFeedback = (type: "like" | "dislike") => {
+ onFeedback(message.id, type)
+ setShowMenu(false)
+ }
+
+ const handleShare = () => {
+ onShare(message)
+ setShowMenu(false)
+ }
+
+ const handleGeneratePDF = () => {
+ onGeneratePDF?.(message.id)
+ setShowMenu(false)
+ }
+
+ const handleGenerateWord = () => {
+ onGenerateWord?.(message.id)
+ setShowMenu(false)
+ }
+
+ const handleEdit = () => {
+ onEdit?.(message)
+ setShowMenu(false)
+ }
+
+ if (message.role === "user") {
+ return (
+
+
:
}
+ label="Copy"
+ onClick={handleCopy}
+ isActive={hasCopied}
+ />
+ {onEdit && (
+
}
+ label="Edit"
+ onClick={handleEdit}
+ />
+ )}
+
+ )
+ }
+
+ if (compact) {
+ return (
+
+
:
}
+ label="Copy"
+ onClick={handleCopy}
+ isActive={hasCopied}
+ />
+
}
+ label="Regenerate"
+ onClick={handleRegenerate}
+ />
+ {showFeedback && (
+ <>
+
}
+ label="Like"
+ onClick={() => handleFeedback("like")}
+ isActive={message.feedback_type === "like"}
+ />
+
}
+ label="Dislike"
+ onClick={() => handleFeedback("dislike")}
+ isActive={message.feedback_type === "dislike"}
+ />
+ >
+ )}
+
+
}
+ label="More"
+ onClick={() => setShowMenu(!showMenu)}
+ />
+ {showMenu && (
+
+
+
+ Share
+
+ {onGeneratePDF && (
+
+
+ Export as PDF
+
+ )}
+ {onGenerateWord && (
+
+
+ Export as Word
+
+ )}
+
+ )}
+
+
+ )
+ }
+
+ return (
+
+
:
}
+ label="Copy"
+ onClick={handleCopy}
+ isActive={hasCopied}
+ />
+
}
+ label="Regenerate"
+ onClick={handleRegenerate}
+ />
+ {showFeedback && (
+ <>
+
}
+ label="Like"
+ onClick={() => handleFeedback("like")}
+ isActive={message.feedback_type === "like"}
+ />
+
}
+ label="Dislike"
+ onClick={() => handleFeedback("dislike")}
+ isActive={message.feedback_type === "dislike"}
+ />
+ >
+ )}
+
}
+ label="Share"
+ onClick={handleShare}
+ />
+ {onGeneratePDF && (
+
}
+ label="PDF"
+ onClick={handleGeneratePDF}
+ />
+ )}
+ {onGenerateWord && (
+
}
+ label="Word"
+ onClick={handleGenerateWord}
+ />
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/chat/README.md b/src/components/chat/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..e131661141a77076350e0720c8f328c589a7db7a
--- /dev/null
+++ b/src/components/chat/README.md
@@ -0,0 +1,416 @@
+# AmaniQuery Chat Interface - Redesigned Components
+
+This document describes the new AmaniQuery chat interface components that provide a cleaner, more streamlined user experience with enhanced features.
+
+## Overview
+
+The redesigned interface focuses on:
+- **Clean, minimal design** - Reduced visual clutter and better organization
+- **Real-time thinking process** - Shows reasoning during message generation
+- **Inline citations** - Sources appear within text with hover tooltips
+- **Contextual actions** - Actions appear on hover with compact menu options
+- **Smooth animations** - Enhanced user experience with thoughtful transitions
+- **Better source display** - Improved source presentation and filtering
+
+## Component Architecture
+
+### Core Components
+
+#### 1. `AmaniMessage.tsx`
+The main message component with clean design and inline citations.
+
+**Features:**
+- Clean avatar design with user/assistant distinction
+- Inline citations with hover tooltips
+- Markdown rendering with syntax highlighting
+- Contextual action buttons (appear on hover)
+- Smooth animations and transitions
+
+**Usage:**
+```tsx
+
+```
+
+#### 2. `ThinkingIndicator.tsx`
+Real-time thinking process visualization.
+
+**Features:**
+- Shows thinking steps during message generation
+- Expandable/collapsible with smooth animations
+- Step-by-step progress indication
+- Compact mode for minimal display
+- Animated thinking indicators
+
+**Usage:**
+```tsx
+
+```
+
+#### 3. `MessageActions.tsx`
+Contextual message actions with compact menu.
+
+**Features:**
+- Actions appear on hover
+- Compact mode with dropdown menu
+- Full mode with all buttons visible
+- Feedback integration (like/dislike)
+- Export options (PDF/Word)
+- Share functionality
+
+**Usage:**
+```tsx
+
+```
+
+#### 4. `AmaniInput.tsx`
+Clean, modern input interface.
+
+**Features:**
+- Auto-resizing textarea
+- Mode switching (Chat/Hybrid/Research)
+- File upload with preview
+- Autocomplete suggestions
+- Voice input support
+- Keyboard shortcuts
+- Attachment management
+
+**Usage:**
+```tsx
+
+```
+
+#### 5. `SourcePanel.tsx`
+Enhanced source display with multiple variants.
+
+**Features:**
+- Panel, inline, and minimal display modes
+- Category filtering
+- Source icons and colors
+- Expandable/collapsible sections
+- Hover tooltips
+- Source summaries
+
+**Usage:**
+```tsx
+
+```
+
+#### 6. `AmaniMessageList.tsx`
+Streamlined message list with conversation grouping.
+
+**Features:**
+- Message grouping by conversation turn
+- Inline thinking indicators
+- Contextual action menus
+- Source integration
+- Welcome screen
+- Loading states
+- Smooth scrolling
+
+**Usage:**
+```tsx
+
+```
+
+#### 7. `AmaniChat.tsx`
+Main chat orchestrator component.
+
+**Features:**
+- Complete chat interface
+- Session management
+- File upload handling
+- Streaming responses
+- Error handling
+- OAuth integration
+- Real-time updates
+
+**Usage:**
+```tsx
+
+```
+
+## Key Improvements
+
+### 1. Visual Design
+- **Cleaner layout** - Reduced visual noise and better spacing
+- **Better typography** - Improved text hierarchy and readability
+- **Consistent colors** - Harmonized color scheme across components
+- **Modern aesthetics** - Glassmorphism effects and subtle shadows
+
+### 2. User Experience
+- **Inline citations** - Sources appear directly in text with hover previews
+- **Real-time feedback** - Thinking process visible during generation
+- **Contextual actions** - Actions appear when needed, hidden otherwise
+- **Smooth interactions** - Thoughtful animations and transitions
+
+### 3. Performance
+- **Lazy loading** - Components render only when needed
+- **Optimized re-renders** - Efficient state management
+- **Debounced inputs** - Improved responsiveness
+- **Streaming support** - Real-time message updates
+
+### 4. Accessibility
+- **Keyboard navigation** - Full keyboard support
+- **Screen reader support** - Proper ARIA labels and roles
+- **High contrast** - Good color contrast ratios
+- **Focus management** - Clear focus indicators
+
+## Animation System
+
+The interface includes a comprehensive animation system:
+
+### Message Animations
+```css
+.animate-message-in /* Slide-in for new messages */
+.animate-thinking /* Pulse for thinking state */
+.animate-citation-hover /* Glow for citation hover */
+```
+
+### Interaction Animations
+```css
+.animate-action-in /* Fade-in for action buttons */
+.animate-tooltip /* Appear for tooltips */
+.animate-input-focus /* Focus animation for inputs */
+```
+
+### Panel Animations
+```css
+.animate-thinking-expand /* Expand for thinking panel */
+.animate-source-slide /* Slide for source panels */
+```
+
+## Usage Examples
+
+### Basic Chat Interface
+```tsx
+import { AmaniChat } from '@/components/chat/AmaniChat'
+
+export default function ChatPage() {
+ return (
+
+ )
+}
+```
+
+### Custom Message List
+```tsx
+import { AmaniMessageList } from '@/components/chat/AmaniMessageList'
+
+function CustomChat({ messages, onSendMessage }) {
+ return (
+
+ )
+}
+```
+
+### Thinking Process Integration
+```tsx
+import { ThinkingIndicator } from '@/components/chat/ThinkingIndicator'
+
+function ChatWithThinking({ isThinking, thinkingSteps }) {
+ return (
+
+
+ {/* Other chat components */}
+
+ )
+}
+```
+
+## Testing
+
+The components include comprehensive test coverage:
+
+```bash
+# Run all chat component tests
+npm test frontend/src/components/chat
+
+# Run specific component tests
+npm test AmaniMessage.test.tsx
+npm test AmaniChat.test.tsx
+
+# Run with coverage
+npm test -- --coverage frontend/src/components/chat
+```
+
+## Migration Guide
+
+### From Old Components
+
+1. **Replace MessageList with AmaniMessageList**
+ ```tsx
+ // Old
+
+
+ // New
+
+ ```
+
+2. **Replace ChatInput with AmaniInput**
+ ```tsx
+ // Old
+
+
+ // New
+
+ ```
+
+3. **Replace ThinkingProcess with ThinkingIndicator**
+ ```tsx
+ // Old
+
+
+ // New
+
+ ```
+
+## Best Practices
+
+### 1. State Management
+- Keep message state in parent components
+- Use local state for UI interactions (hover, expand, etc.)
+- Implement proper cleanup for subscriptions
+
+### 2. Performance
+- Use React.memo for expensive components
+- Implement proper key props for lists
+- Debounce user inputs
+
+### 3. Error Handling
+- Always handle API errors gracefully
+- Show user-friendly error messages
+- Implement retry mechanisms
+
+### 4. Accessibility
+- Use semantic HTML elements
+- Provide proper ARIA labels
+- Ensure keyboard navigation works
+- Test with screen readers
+
+## Future Enhancements
+
+### Planned Features
+- **Voice input** - Speech-to-text integration
+- **Rich media** - Enhanced image/video handling
+- **Collaborative features** - Multi-user chat sessions
+- **Advanced search** - Better source discovery
+- **Mobile optimization** - Enhanced mobile experience
+
+### Performance Improvements
+- **Virtual scrolling** - For large message lists
+- **Message caching** - Better performance for repeated queries
+- **Background sync** - Offline message queuing
+- **Progressive loading** - Load components as needed
+
+## Support
+
+For issues, questions, or contributions:
+- Check the test files for usage examples
+- Review the component props for customization options
+- Submit issues through the project's issue tracker
+- Follow the coding standards and guidelines
+
+---
+
+The new AmaniQuery chat interface provides a significantly improved user experience with cleaner design, better organization, and enhanced functionality while maintaining all the powerful features of the original implementation.
\ No newline at end of file
diff --git a/src/components/chat/ShareSheet.tsx b/src/components/chat/ShareSheet.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b27b4f001325fa7356aa45e06e28edf0b334fc1f
--- /dev/null
+++ b/src/components/chat/ShareSheet.tsx
@@ -0,0 +1,192 @@
+import React from "react"
+import { Button } from "@/components/ui/button"
+import { Loader2, X, Copy, ImageIcon, ExternalLink, LogIn, Link2 } from "lucide-react"
+import type { Message, SharePlatform, ShareSheetState } from "./types"
+import { SHARE_PLATFORMS } from "./constants"
+import { motion, AnimatePresence } from "framer-motion"
+
+interface ShareSheetProps {
+ message: Message
+ shareSheet: ShareSheetState
+ onClose: () => void
+ onChangePlatform: (message: Message, platform: SharePlatform) => void
+ onCopyContent: () => void
+ onGenerateImage: (message: Message) => void
+ onOpenIntent: (message: Message) => void
+ onAuthenticate: (platform: SharePlatform) => void
+ onPostDirectly: (message: Message) => void
+ platformTokens: Record
+}
+
+export function ShareSheet({
+ message,
+ shareSheet,
+ onClose,
+ onChangePlatform,
+ onCopyContent,
+ onGenerateImage,
+ onOpenIntent,
+ onAuthenticate,
+ onPostDirectly,
+ platformTokens
+}: ShareSheetProps) {
+ return (
+
+
+
+
+
Social Share
+
Turn this answer into a post
+
+
+
+
+
+
+
+ {SHARE_PLATFORMS.map((platform) => (
+
onChangePlatform(message, platform.id)}
+ className={`group relative overflow-hidden rounded-2xl border p-2.5 md:p-3 text-left transition-all duration-200 min-h-[60px] ${
+ shareSheet.platform === platform.id
+ ? "border-primary/50 bg-primary/5 ring-1 ring-primary/20"
+ : "border-white/10 bg-white/[0.04] hover:border-white/20 hover:bg-white/10"
+ }`}
+ >
+
+ {platform.icon}
+ {platform.label}
+
+ {platform.description}
+
+ {shareSheet.platform === platform.id && (
+
+ )}
+
+ ))}
+
+
+
+ {shareSheet.isLoading ? (
+
+
+
Formatting for {SHARE_PLATFORMS.find(p => p.id === shareSheet.platform)?.label}...
+
+ ) : shareSheet.preview ? (
+
+
+
Preview
+ {shareSheet.preview.character_count && (
+
{shareSheet.preview.character_count} chars
+ )}
+
+
+
+ {Array.isArray(shareSheet.preview.content)
+ ? shareSheet.preview.content.join("\n\n")
+ : shareSheet.preview.content}
+
+
+ {shareSheet.preview.hashtags && shareSheet.preview.hashtags.length > 0 && (
+
+ {shareSheet.preview.hashtags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+ ) : null}
+
+ {shareSheet.shareError && (
+
+ {shareSheet.shareError}
+
+ )}
+ {shareSheet.success && (
+
+ {shareSheet.success}
+
+ )}
+
+
+
+
+
+ Copy text
+
+
+ onGenerateImage(message)}
+ disabled={!shareSheet.preview || shareSheet.generatingImage}
+ >
+ {shareSheet.generatingImage ? : }
+ Image
+
+
+ onOpenIntent(message)}
+ disabled={!shareSheet.preview || shareSheet.shareLinkLoading}
+ >
+ {shareSheet.shareLinkLoading ? : }
+ Share
+
+
+ {/* Conditional buttons for Auth and Direct Posting */}
+ {!platformTokens[shareSheet.platform] && (
+ onAuthenticate(shareSheet.platform)}
+ disabled={shareSheet.shareLinkLoading}
+ title="Authenticate to post directly"
+ >
+ {shareSheet.shareLinkLoading ? : }
+
+ )}
+
+ onPostDirectly(message)}
+ disabled={!shareSheet.preview || shareSheet.posting || !platformTokens[shareSheet.platform]}
+ title="Post directly (Beta)"
+ >
+ {shareSheet.posting ? : }
+
+
+
+
+ )
+}
diff --git a/src/components/chat/SourcePanel.tsx b/src/components/chat/SourcePanel.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..1e853ed7636a073363f9dd4d7f609b33aeade703
--- /dev/null
+++ b/src/components/chat/SourcePanel.tsx
@@ -0,0 +1,354 @@
+"use client"
+
+import { useState } from "react"
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import {
+ ExternalLink,
+ ChevronDown,
+ ChevronRight,
+ Globe,
+ BookOpen,
+ FileText,
+ Newspaper,
+ GraduationCap,
+ LinkIcon,
+ Filter
+} from "lucide-react"
+import type { Source } from "./types"
+
+interface SourcePanelProps {
+ sources: Source[]
+ isOpen: boolean
+ onToggle: () => void
+ className?: string
+ variant?: "panel" | "inline" | "minimal"
+ maxHeight?: string
+}
+
+interface SourceCardProps {
+ source: Source
+ index: number
+ variant?: "detailed" | "compact"
+ onView: (source: Source) => void
+}
+
+function getSourceIcon(category: string) {
+ switch (category.toLowerCase()) {
+ case "academic":
+ return
+ case "news":
+ return
+ case "documentation":
+ return
+ case "blog":
+ return
+ default:
+ return
+ }
+}
+
+function getCategoryColor(category: string) {
+ switch (category.toLowerCase()) {
+ case "academic":
+ return "bg-blue-100 text-blue-800 border-blue-200"
+ case "news":
+ return "bg-red-100 text-red-800 border-red-200"
+ case "documentation":
+ return "bg-green-100 text-green-800 border-green-200"
+ case "blog":
+ return "bg-purple-100 text-purple-800 border-purple-200"
+ default:
+ return "bg-gray-100 text-gray-800 border-gray-200"
+ }
+}
+
+function SourceCard({ source, index, variant = "detailed", onView }: SourceCardProps) {
+ const handleClick = () => {
+ window.open(source.url, '_blank', 'noopener,noreferrer')
+ onView(source)
+ }
+
+ if (variant === "compact") {
+ return (
+
+
+ {index + 1}
+
+
+
+ {getSourceIcon(source.category)}
+ {source.source_name}
+
+
+ {source.title}
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ {index + 1}
+
+
+
+
+
+ {getSourceIcon(source.category)}
+ {source.category}
+
+ {source.source_name}
+
+
+
+
+ {source.title}
+
+
+
+
+ {source.excerpt}
+
+
+
+
+ View Source
+
+
+
+
+
+ )
+}
+
+export function SourcePanel({
+ sources,
+ isOpen,
+ onToggle,
+ className,
+ variant = "panel",
+ maxHeight = "400px"
+}: SourcePanelProps) {
+ const [viewedSources, setViewedSources] = useState>(new Set())
+ const [filterCategory, setFilterCategory] = useState("all")
+
+ if (variant === "inline") {
+ return (
+
+
+
+
+ Sources
+
+ {sources.length}
+
+
+
+ {isOpen ? (
+ <>
+
+ Hide
+ >
+ ) : (
+ <>
+
+ Show
+ >
+ )}
+
+
+
+ {isOpen && (
+
+ {sources.slice(0, 3).map((source, index) => (
+ setViewedSources(prev => new Set(prev).add(source.url))}
+ />
+ ))}
+ {sources.length > 3 && (
+
+ View {sources.length - 3} more sources
+
+ )}
+
+ )}
+
+ )
+ }
+
+ if (variant === "minimal") {
+ return (
+
+
+ Sources:
+ {sources.slice(0, 5).map((source, index) => (
+ window.open(source.url, '_blank', 'noopener,noreferrer')}
+ >
+ {index + 1}. {source.source_name}
+
+ ))}
+ {sources.length > 5 && (
+
+ +{sources.length - 5} more
+
+ )}
+
+ )
+ }
+
+ // Panel variant (default)
+ const categories = Array.from(new Set(sources.map(s => s.category)))
+ const filteredSources = filterCategory === "all"
+ ? sources
+ : sources.filter(s => s.category === filterCategory)
+
+ return (
+
+
+
+
+ Sources
+
+ {sources.length}
+
+
+
+ {isOpen ? (
+ <>
+
+ Collapse
+ >
+ ) : (
+ <>
+
+ Expand
+ >
+ )}
+
+
+
+ {categories.length > 1 && (
+
+
+
+ setFilterCategory("all")}
+ >
+ All
+
+ {categories.map(category => (
+ setFilterCategory(category)}
+ >
+ {category}
+
+ ))}
+
+
+ )}
+
+
+ {isOpen && (
+
+
+
+ {filteredSources.map((source, index) => (
+ setViewedSources(prev => new Set(prev).add(source.url))}
+ />
+ ))}
+
+
+
+ )}
+
+ )
+}
+
+interface SourceSummaryProps {
+ sources: Source[]
+ className?: string
+}
+
+export function SourceSummary({ sources, className }: SourceSummaryProps) {
+ if (!sources || sources.length === 0) return null
+
+ const categories = Array.from(new Set(sources.map(s => s.category)))
+ const sourcesByCategory = categories.map(category => ({
+ category,
+ count: sources.filter(s => s.category === category).length,
+ sources: sources.filter(s => s.category === category)
+ }))
+
+ return (
+
+
+ Based on {sources.length} sources
+ {sourcesByCategory.map(({ category, count }) => (
+
+ {getSourceIcon(category)}
+ {count} {category}
+
+ ))}
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/chat/ThinkingIndicator.tsx b/src/components/chat/ThinkingIndicator.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..89a01c01237b79448ab0a16cc676caeb02cb206f
--- /dev/null
+++ b/src/components/chat/ThinkingIndicator.tsx
@@ -0,0 +1,178 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { cn } from "@/lib/utils"
+import { Brain, ChevronDown, ChevronRight, Check, Clock, Search, Lightbulb } from "lucide-react"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+
+interface ThinkingStep {
+ id: string
+ title: string
+ status: "pending" | "active" | "completed"
+ duration?: number
+ details?: string
+}
+
+interface ThinkingIndicatorProps {
+ isActive: boolean
+ steps?: ThinkingStep[]
+ currentStep?: number
+ className?: string
+ onToggle?: (expanded: boolean) => void
+ defaultExpanded?: boolean
+}
+
+const defaultThinkingSteps: ThinkingStep[] = [
+ { id: "1", title: "Understanding your question", status: "pending" },
+ { id: "2", title: "Searching relevant information", status: "pending" },
+ { id: "3", title: "Analyzing sources", status: "pending" },
+ { id: "4", title: "Formulating response", status: "pending" }
+]
+
+export function ThinkingIndicator({
+ isActive,
+ steps = defaultThinkingSteps,
+ currentStep = 0,
+ className,
+ onToggle,
+ defaultExpanded = false
+}: ThinkingIndicatorProps) {
+ const [isExpanded, setIsExpanded] = useState(defaultExpanded)
+ const [activeSteps, setActiveSteps] = useState(steps)
+
+ useEffect(() => {
+ if (isActive) {
+ const timer = setInterval(() => {
+ setActiveSteps(prev => {
+ const newSteps = [...prev]
+ const currentActiveIndex = newSteps.findIndex(step => step.status === "active")
+
+ if (currentActiveIndex >= 0 && currentActiveIndex < newSteps.length - 1) {
+ newSteps[currentActiveIndex].status = "completed"
+ newSteps[currentActiveIndex + 1].status = "active"
+ } else if (currentActiveIndex === -1 && newSteps.length > 0) {
+ newSteps[0].status = "active"
+ }
+
+ return newSteps
+ })
+ }, 2000)
+
+ return () => clearInterval(timer)
+ }
+ }, [isActive])
+
+ useEffect(() => {
+ if (!isActive) {
+ setActiveSteps(steps.map(step => ({ ...step, status: "pending" })))
+ }
+ }, [isActive, steps])
+
+ const handleToggle = () => {
+ const newExpanded = !isExpanded
+ setIsExpanded(newExpanded)
+ onToggle?.(newExpanded)
+ }
+
+ const getStepIcon = (status: string) => {
+ switch (status) {
+ case "completed":
+ return
+ case "active":
+ return
+ default:
+ return
+ }
+ }
+
+ if (!isActive) return null
+
+ return (
+
+
+
+
+
+ Thinking
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+
+ Analyzing...
+
+
+
+
+ {isExpanded && (
+
+
+ {activeSteps.map((step, index) => (
+
+ {getStepIcon(step.status)}
+
+
+ {step.title}
+
+ {step.details && step.status === "active" && (
+
+ {step.details}
+
+ )}
+
+ {step.status === "active" && (
+
+ )}
+ {step.status === "completed" && step.duration && (
+
+ {step.duration}ms
+
+ )}
+
+ ))}
+
+
+ )}
+
+ )
+}
+
+interface CompactThinkingIndicatorProps {
+ isActive: boolean
+ className?: string
+}
+
+export function CompactThinkingIndicator({ isActive, className }: CompactThinkingIndicatorProps) {
+ if (!isActive) return null
+
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/chat/VideoPreview.tsx b/src/components/chat/VideoPreview.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..959fabc51a847dcb2fd929f410d208bb8095c968
--- /dev/null
+++ b/src/components/chat/VideoPreview.tsx
@@ -0,0 +1,387 @@
+"use client"
+
+import { useState, useRef, useEffect } from "react"
+import { Button } from "@/components/ui/button"
+import {
+ Play,
+ Pause,
+ Volume2,
+ VolumeX,
+ Maximize2,
+ Minimize2,
+ Loader2,
+ RefreshCw,
+ AlertCircle,
+ FileVideo,
+ SkipBack,
+ SkipForward,
+} from "lucide-react"
+import { Slider } from "@/components/ui/slider"
+import { formatDuration } from "./media-api"
+
+interface VideoPreviewProps {
+ src: string
+ poster?: string
+ filename?: string
+ transcript?: string
+ frameUrls?: string[]
+ className?: string
+ showTranscript?: boolean
+ autoPlay?: boolean
+}
+
+export function VideoPreview({
+ src,
+ poster,
+ filename,
+ transcript,
+ frameUrls,
+ className = "",
+ showTranscript = true,
+ autoPlay = false,
+}: VideoPreviewProps) {
+ const videoRef = useRef(null)
+ const containerRef = useRef(null)
+ const [isPlaying, setIsPlaying] = useState(false)
+ const [isMuted, setIsMuted] = useState(false)
+ const [isFullscreen, setIsFullscreen] = useState(false)
+ const [currentTime, setCurrentTime] = useState(0)
+ const [duration, setDuration] = useState(0)
+ const [isLoading, setIsLoading] = useState(true)
+ const [error, setError] = useState(false)
+ const [volume, setVolume] = useState(1)
+ const [showControls, setShowControls] = useState(true)
+ const [showFrames, setShowFrames] = useState(false)
+ const controlsTimeoutRef = useRef(null)
+
+ useEffect(() => {
+ const video = videoRef.current
+ if (!video) return
+
+ const handleLoadedMetadata = () => {
+ setDuration(video.duration)
+ setIsLoading(false)
+ }
+
+ const handleTimeUpdate = () => {
+ setCurrentTime(video.currentTime)
+ }
+
+ const handleEnded = () => {
+ setIsPlaying(false)
+ }
+
+ const handleError = () => {
+ setError(true)
+ setIsLoading(false)
+ }
+
+ const handleCanPlay = () => {
+ setIsLoading(false)
+ }
+
+ const handlePlay = () => setIsPlaying(true)
+ const handlePause = () => setIsPlaying(false)
+
+ video.addEventListener("loadedmetadata", handleLoadedMetadata)
+ video.addEventListener("timeupdate", handleTimeUpdate)
+ video.addEventListener("ended", handleEnded)
+ video.addEventListener("error", handleError)
+ video.addEventListener("canplay", handleCanPlay)
+ video.addEventListener("play", handlePlay)
+ video.addEventListener("pause", handlePause)
+
+ return () => {
+ video.removeEventListener("loadedmetadata", handleLoadedMetadata)
+ video.removeEventListener("timeupdate", handleTimeUpdate)
+ video.removeEventListener("ended", handleEnded)
+ video.removeEventListener("error", handleError)
+ video.removeEventListener("canplay", handleCanPlay)
+ video.removeEventListener("play", handlePlay)
+ video.removeEventListener("pause", handlePause)
+ }
+ }, [src])
+
+ // Handle fullscreen changes
+ useEffect(() => {
+ const handleFullscreenChange = () => {
+ setIsFullscreen(!!document.fullscreenElement)
+ }
+
+ document.addEventListener("fullscreenchange", handleFullscreenChange)
+ return () => {
+ document.removeEventListener("fullscreenchange", handleFullscreenChange)
+ }
+ }, [])
+
+ // Auto-hide controls
+ const resetControlsTimeout = () => {
+ if (controlsTimeoutRef.current) {
+ clearTimeout(controlsTimeoutRef.current)
+ }
+ setShowControls(true)
+ if (isPlaying) {
+ controlsTimeoutRef.current = setTimeout(() => {
+ setShowControls(false)
+ }, 3000)
+ }
+ }
+
+ const togglePlay = () => {
+ const video = videoRef.current
+ if (!video) return
+
+ if (isPlaying) {
+ video.pause()
+ } else {
+ video.play()
+ }
+ }
+
+ const toggleMute = () => {
+ const video = videoRef.current
+ if (!video) return
+
+ video.muted = !isMuted
+ setIsMuted(!isMuted)
+ }
+
+ const toggleFullscreen = async () => {
+ const container = containerRef.current
+ if (!container) return
+
+ if (!document.fullscreenElement) {
+ await container.requestFullscreen()
+ } else {
+ await document.exitFullscreen()
+ }
+ }
+
+ const handleSeek = (value: number[]) => {
+ const video = videoRef.current
+ if (!video) return
+
+ video.currentTime = value[0]
+ setCurrentTime(value[0])
+ }
+
+ const handleVolumeChange = (value: number[]) => {
+ const video = videoRef.current
+ if (!video) return
+
+ const newVolume = value[0]
+ video.volume = newVolume
+ setVolume(newVolume)
+ setIsMuted(newVolume === 0)
+ }
+
+ const skip = (seconds: number) => {
+ const video = videoRef.current
+ if (!video) return
+
+ video.currentTime = Math.max(0, Math.min(duration, video.currentTime + seconds))
+ }
+
+ const handleRetry = () => {
+ setError(false)
+ setIsLoading(true)
+ const video = videoRef.current
+ if (video) {
+ video.load()
+ }
+ }
+
+ if (error) {
+ return (
+
+
+
Failed to load video
+
+
+ Retry
+
+
+ )
+ }
+
+ return (
+
+ {/* Header with filename */}
+ {filename && (
+
+
+ {filename}
+
+ )}
+
+ {/* Video container */}
+
setShowControls(true)}
+ onMouseLeave={() => isPlaying && setShowControls(false)}
+ >
+
+
+ {/* Loading overlay */}
+ {isLoading && (
+
+
+
+ )}
+
+ {/* Play button overlay */}
+ {!isPlaying && !isLoading && (
+
+ )}
+
+ {/* Controls overlay */}
+
+ {/* Progress bar */}
+
+
+ {/* Control buttons */}
+
+
+ {/* Play/Pause */}
+
+ {isPlaying ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Skip buttons */}
+
skip(-10)}
+ className="h-8 w-8 text-white hover:bg-white/20"
+ >
+
+
+
skip(10)}
+ className="h-8 w-8 text-white hover:bg-white/20"
+ >
+
+
+
+ {/* Volume */}
+
+
+ {isMuted || volume === 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* Time display */}
+
+ {formatDuration(currentTime)} / {formatDuration(duration)}
+
+
+
+ {/* Fullscreen */}
+
+ {isFullscreen ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {/* Extracted frames preview */}
+ {frameUrls && frameUrls.length > 0 && (
+
+
setShowFrames(!showFrames)}
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors"
+ >
+ {showFrames ? "Hide" : "Show"} extracted frames ({frameUrls.length})
+
+
+ {showFrames && (
+
+ {frameUrls.map((url, idx) => (
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* Transcript section */}
+ {showTranscript && transcript && (
+
+
Audio Transcript:
+
+ “{transcript}”
+
+
+ )}
+
+ )
+}
diff --git a/src/components/chat/WelcomeScreen.tsx b/src/components/chat/WelcomeScreen.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..f9c17d398da16b33591c7befcc1e2cd88007ddae
--- /dev/null
+++ b/src/components/chat/WelcomeScreen.tsx
@@ -0,0 +1,73 @@
+import { Bot, Search, Sparkles, ArrowUpRight } from "lucide-react"
+import { Badge } from "@/components/ui/badge"
+import { SUGGESTED_QUESTIONS, RESEARCH_SUGGESTED_QUESTIONS } from "./constants"
+
+interface WelcomeScreenProps {
+ isResearchMode: boolean
+ useHybrid: boolean
+ onSuggestionClick: (suggestion: string) => void
+}
+
+export function WelcomeScreen({
+ isResearchMode,
+ useHybrid,
+ onSuggestionClick
+}: WelcomeScreenProps) {
+ return (
+
+
+
+
+
+ Welcome to AmaniQuery
+
+ {isResearchMode && (
+
+
+ Research Mode
+
+ )}
+ {useHybrid && !isResearchMode && (
+
+
+ Hybrid Mode
+
+ )}
+
+ {isResearchMode
+ ? "Submit a detailed Kenyan legal question and receive structured analysis built for citations and downstream reporting."
+ : useHybrid
+ ? "Enhanced RAG with hybrid encoder and adaptive retrieval. Ask about Kenyan law, parliament, or current affairs with improved accuracy."
+ : "Ask about Kenyan law, parliament, or current affairs. Answers stream in real time with sources you can trust."}
+
+
+ ⌘K
+ to focus input
+ •
+ ⌘↵
+ to send
+
+
+ {(isResearchMode ? RESEARCH_SUGGESTED_QUESTIONS : SUGGESTED_QUESTIONS).map((question) => (
+
onSuggestionClick(question.title)}
+ className="w-full rounded-3xl border border-white/10 bg-white/5 p-3 md:p-4 text-left transition hover:border-primary/40 hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 min-h-[60px]"
+ >
+
+
+
+
+
+
{question.title}
+
{question.description}
+
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/chat/__tests__/AmaniChat.test.tsx b/src/components/chat/__tests__/AmaniChat.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..194333631adffaf2f318b72fc1f5e6666917de12
--- /dev/null
+++ b/src/components/chat/__tests__/AmaniChat.test.tsx
@@ -0,0 +1,440 @@
+import { render, screen, fireEvent, waitFor } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { AmaniChat } from "../AmaniChat"
+import { AmaniMessage } from "../AmaniMessage"
+import { AmaniInput } from "../AmaniInput"
+import { AmaniMessageList } from "../AmaniMessageList"
+import { ThinkingIndicator } from "../ThinkingIndicator"
+import { MessageActions } from "../MessageActions"
+import { SourcePanel } from "../SourcePanel"
+
+// Mock the auth context and other dependencies
+jest.mock("@/lib/auth-context", () => ({
+ useAuth: () => ({
+ isAuthenticated: true,
+ loading: false
+ })
+}))
+
+jest.mock("sonner", () => ({
+ toast: {
+ success: jest.fn(),
+ error: jest.fn()
+ }
+}))
+
+// Mock fetch
+global.fetch = jest.fn()
+
+const mockMessages = [
+ {
+ id: "1",
+ session_id: "test-session",
+ role: "user" as const,
+ content: "What is the capital of Kenya?",
+ created_at: new Date().toISOString()
+ },
+ {
+ id: "2",
+ session_id: "test-session",
+ role: "assistant" as const,
+ content: "The capital of Kenya is Nairobi.",
+ created_at: new Date().toISOString(),
+ sources: [
+ {
+ title: "Kenya - Wikipedia",
+ url: "https://en.wikipedia.org/wiki/Kenya",
+ source_name: "Wikipedia",
+ category: "encyclopedia",
+ excerpt: "Kenya is a country in East Africa with its capital in Nairobi."
+ }
+ ]
+ }
+]
+
+describe("AmaniMessage Component", () => {
+ it("renders user message correctly", () => {
+ const userMessage = mockMessages[0]
+ const { container } = render(
+
+ )
+
+ expect(container.textContent).toContain("What is the capital of Kenya?")
+ expect(container.querySelector("[data-user-avatar]")).toBeTruthy()
+ })
+
+ it("renders assistant message with citations", () => {
+ const assistantMessage = mockMessages[1]
+ const { container } = render(
+
+ )
+
+ expect(container.textContent).toContain("The capital of Kenya is Nairobi.")
+ expect(container.querySelector("[data-assistant-avatar]")).toBeTruthy()
+ // Should render citation
+ expect(container.textContent).toContain("[1]")
+ })
+
+ it("handles copy action", async () => {
+ const mockCopy = jest.fn()
+ const assistantMessage = mockMessages[1]
+
+ render(
+
+ )
+
+ const copyButton = screen.getByRole("button", { name: /copy/i })
+ await userEvent.click(copyButton)
+
+ expect(mockCopy).toHaveBeenCalledWith("The capital of Kenya is Nairobi.")
+ })
+
+ it("shows feedback buttons on hover", async () => {
+ const assistantMessage = mockMessages[1]
+ const { container } = render(
+
+ )
+
+ const messageElement = container.querySelector("[data-message]")
+ if (messageElement) {
+ fireEvent.mouseEnter(messageElement)
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /like/i })).toBeInTheDocument()
+ expect(screen.getByRole("button", { name: /dislike/i })).toBeInTheDocument()
+ })
+ }
+ })
+})
+
+describe("ThinkingIndicator Component", () => {
+ it("renders when active", () => {
+ render( )
+
+ expect(screen.getByText("Thinking")).toBeInTheDocument()
+ expect(screen.getByRole("button", { name: /expand/i })).toBeInTheDocument()
+ })
+
+ it("does not render when inactive", () => {
+ render( )
+
+ expect(screen.queryByText("Thinking")).not.toBeInTheDocument()
+ })
+
+ it("expands and collapses", async () => {
+ render( )
+
+ const expandButton = screen.getByRole("button", { name: /expand/i })
+ await userEvent.click(expandButton)
+
+ // Should show thinking steps when expanded
+ expect(screen.getByText("Understanding your question")).toBeInTheDocument()
+ })
+})
+
+describe("MessageActions Component", () => {
+ const mockMessage = mockMessages[1]
+ const mockHandlers = {
+ onCopy: jest.fn(),
+ onRegenerate: jest.fn(),
+ onFeedback: jest.fn(),
+ onShare: jest.fn()
+ }
+
+ it("renders all action buttons", () => {
+ render( )
+
+ expect(screen.getByRole("button", { name: /copy/i })).toBeInTheDocument()
+ expect(screen.getByRole("button", { name: /regenerate/i })).toBeInTheDocument()
+ expect(screen.getByRole("button", { name: /like/i })).toBeInTheDocument()
+ expect(screen.getByRole("button", { name: /dislike/i })).toBeInTheDocument()
+ expect(screen.getByRole("button", { name: /share/i })).toBeInTheDocument()
+ })
+
+ it("handles feedback correctly", async () => {
+ render( )
+
+ const likeButton = screen.getByRole("button", { name: /like/i })
+ await userEvent.click(likeButton)
+
+ expect(mockHandlers.onFeedback).toHaveBeenCalledWith("2", "like")
+ })
+
+ it("shows compact mode", () => {
+ render( )
+
+ // In compact mode, should have more menu
+ expect(screen.getByRole("button", { name: /more/i })).toBeInTheDocument()
+ })
+})
+
+describe("SourcePanel Component", () => {
+ const mockSources = [
+ {
+ title: "Test Source 1",
+ url: "https://example1.com",
+ source_name: "Example 1",
+ category: "news",
+ excerpt: "This is a test excerpt for source 1."
+ },
+ {
+ title: "Test Source 2",
+ url: "https://example2.com",
+ source_name: "Example 2",
+ category: "academic",
+ excerpt: "This is a test excerpt for source 2."
+ }
+ ]
+
+ it("renders panel variant", () => {
+ render(
+
+ )
+
+ expect(screen.getByText("Sources")).toBeInTheDocument()
+ expect(screen.getByText("Test Source 1")).toBeInTheDocument()
+ expect(screen.getByText("Test Source 2")).toBeInTheDocument()
+ })
+
+ it("renders inline variant", () => {
+ render(
+
+ )
+
+ expect(screen.getByText("Sources")).toBeInTheDocument()
+ expect(screen.getByText("Example 1")).toBeInTheDocument()
+ })
+
+ it("filters by category", async () => {
+ render(
+
+ )
+
+ const newsFilter = screen.getByRole("button", { name: /news/i })
+ await userEvent.click(newsFilter)
+
+ // Should only show news sources
+ expect(screen.getByText("Test Source 1")).toBeInTheDocument()
+ expect(screen.queryByText("Test Source 2")).not.toBeInTheDocument()
+ })
+})
+
+describe("AmaniInput Component", () => {
+ const mockHandlers = {
+ onChange: jest.fn(),
+ onSend: jest.fn(),
+ onFileSelect: jest.fn()
+ }
+
+ it("renders input field with placeholder", () => {
+ render( )
+
+ const input = screen.getByPlaceholderText("Ask anything...")
+ expect(input).toBeInTheDocument()
+ expect(input.tagName).toBe("TEXTAREA")
+ })
+
+ it("handles text input", async () => {
+ render( )
+
+ const input = screen.getByPlaceholderText("Ask anything...")
+ await userEvent.type(input, "Hello AmaniQuery")
+
+ expect(mockHandlers.onChange).toHaveBeenCalledWith("H")
+ expect(mockHandlers.onChange).toHaveBeenCalledWith("He")
+ expect(mockHandlers.onChange).toHaveBeenCalledWith("Hello AmaniQuery")
+ })
+
+ it("sends message on Enter key", async () => {
+ render( )
+
+ const input = screen.getByPlaceholderText("Ask anything...")
+ await userEvent.type(input, "{Enter}")
+
+ expect(mockHandlers.onSend).toHaveBeenCalled()
+ })
+
+ it("shows mode selector", () => {
+ render( )
+
+ expect(screen.getByRole("button", { name: /chat/i })).toBeInTheDocument()
+ })
+
+ it("handles file selection", async () => {
+ const mockFileSelect = jest.fn()
+ render( )
+
+ const fileInput = screen.getByLabelText("file-upload")
+ const file = new File(["test content"], "test.txt", { type: "text/plain" })
+
+ Object.defineProperty(fileInput, "files", {
+ value: [file]
+ })
+
+ fireEvent.change(fileInput)
+
+ expect(mockFileSelect).toHaveBeenCalledWith([file])
+ })
+})
+
+describe("AmaniMessageList Component", () => {
+ it("renders message list", () => {
+ render(
+
+ )
+
+ expect(screen.getByText("What is the capital of Kenya?")).toBeInTheDocument()
+ expect(screen.getByText("The capital of Kenya is Nairobi.")).toBeInTheDocument()
+ })
+
+ it("shows welcome screen when no messages", () => {
+ render(
+
+ )
+
+ expect(screen.getByText(/welcome to amaniquery/i)).toBeInTheDocument()
+ })
+
+ it("shows loading state", () => {
+ render(
+
+ )
+
+ expect(screen.getByText(/amaniquery is thinking/i)).toBeInTheDocument()
+ })
+})
+
+describe("AmaniChat Integration", () => {
+ beforeEach(() => {
+ // Reset fetch mock
+ (global.fetch as jest.Mock).mockReset()
+
+ // Mock successful responses
+ ;(global.fetch as jest.Mock).mockImplementation((url: string) => {
+ if (url.includes('/api/cache/sessions')) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve([]),
+ headers: new Headers()
+ })
+ }
+ if (url.includes('/api/v1/chat/sessions')) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ id: 'test-session', title: 'Test Chat' })
+ })
+ }
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
+ })
+ })
+
+ it("renders complete chat interface", () => {
+ render( )
+
+ // Should have input field
+ expect(screen.getByPlaceholderText("Ask AmaniQuery anything...")).toBeInTheDocument()
+
+ // Should have mode selector
+ expect(screen.getByRole("button", { name: /chat/i })).toBeInTheDocument()
+ })
+
+ it("handles message sending flow", async () => {
+ render( )
+
+ const input = screen.getByPlaceholderText("Ask AmaniQuery anything...")
+ await userEvent.type(input, "Hello AmaniQuery")
+
+ const sendButton = screen.getByRole("button", { name: /send/i })
+ await userEvent.click(sendButton)
+
+ // Input should be cleared
+ expect(input).toHaveValue("")
+ })
+
+ it("shows thinking indicator during response", async () => {
+ // Mock streaming response
+ const mockReader = {
+ read: jest.fn()
+ .mockResolvedValueOnce({ done: false, value: new TextEncoder().encode('data: {"content": "Hello"}\n\n') })
+ .mockResolvedValueOnce({ done: true, value: new Uint8Array() })
+ }
+
+ ;(global.fetch as jest.Mock).mockResolvedValueOnce({
+ ok: true,
+ body: { getReader: () => mockReader }
+ })
+
+ render( )
+
+ const input = screen.getByPlaceholderText("Ask AmaniQuery anything...")
+ await userEvent.type(input, "Test message")
+
+ const sendButton = screen.getByRole("button", { name: /send/i })
+ await userEvent.click(sendButton)
+
+ // Should show loading state
+ await waitFor(() => {
+ expect(screen.getByText(/thinking/i)).toBeInTheDocument()
+ })
+ })
+})
\ No newline at end of file
diff --git a/src/components/chat/api.ts b/src/components/chat/api.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bf1c61f7d15027bb0678606825e5430d4c25c269
--- /dev/null
+++ b/src/components/chat/api.ts
@@ -0,0 +1,112 @@
+/**
+ * Chat API Utilities
+ * Handles session management and file uploads
+ */
+
+export async function createNewSession(
+ firstMessage: string | undefined,
+ API_BASE_URL: string,
+ getAuthHeaders: () => Record,
+ setCurrentSessionId: (id: string) => void,
+ setMessages: (messages: any[]) => void,
+ loadChatHistory: () => void
+): Promise {
+ // Generate title from first message if provided
+ let title = "New Chat"
+ if (firstMessage) {
+ const content = firstMessage.trim()
+ if (content.length <= 50) {
+ title = content
+ } else {
+ title = content.substring(0, 50).split(' ').slice(0, -1).join(' ') + "..."
+ }
+ }
+
+ try {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ ...getAuthHeaders()
+ }
+ const response = await fetch(`${API_BASE_URL}/api/v1/chat/sessions`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ title })
+ })
+ if (response.ok) {
+ const session = await response.json()
+ setCurrentSessionId(session.id)
+ setMessages([])
+ loadChatHistory()
+ return session.id
+ }
+ } catch (error) {
+ console.error("Failed to create session:", error)
+ }
+ return null
+}
+
+export async function loadSession(
+ sessionId: string,
+ API_BASE_URL: string,
+ getAuthHeaders: () => Record,
+ setMessages: (messages: any[]) => void,
+ setCurrentSessionId: (id: string) => void,
+ setShowHistory: (show: boolean) => void
+): Promise {
+ try {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ ...getAuthHeaders()
+ }
+ const response = await fetch(`${API_BASE_URL}/api/v1/chat/sessions/${sessionId}/messages`, {
+ headers
+ })
+ if (response.ok) {
+ const sessionMessages = await response.json()
+ setMessages(sessionMessages)
+ setCurrentSessionId(sessionId)
+ setShowHistory(false)
+ }
+ } catch (error) {
+ console.error("Failed to load session:", error)
+ }
+}
+
+export async function uploadFiles(
+ sessionId: string,
+ files: File[],
+ API_BASE_URL: string,
+ getAuthHeaders: () => Record,
+ setUploadingFiles: (uploading: boolean) => void
+): Promise {
+ const attachmentIds: string[] = []
+ setUploadingFiles(true)
+
+ try {
+ for (const file of files) {
+ const formData = new FormData()
+ formData.append("file", file)
+
+ const headers: Record = {
+ ...getAuthHeaders()
+ }
+ const response = await fetch(`${API_BASE_URL}/api/v1/chat/sessions/${sessionId}/attachments`, {
+ method: "POST",
+ headers,
+ body: formData,
+ })
+
+ if (response.ok) {
+ const data = await response.json()
+ attachmentIds.push(data.attachment.id)
+ } else {
+ const error = await response.json()
+ throw new Error(error.detail || "Failed to upload file")
+ }
+ }
+ } finally {
+ setUploadingFiles(false)
+ }
+
+ return attachmentIds
+}
diff --git a/src/components/chat/constants.tsx b/src/components/chat/constants.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4bf42e94ee96734330882b22bde5ae83daf91d74
--- /dev/null
+++ b/src/components/chat/constants.tsx
@@ -0,0 +1,119 @@
+import { Twitter, Linkedin, Facebook, MessageCircle, Send, Mail, AtSign, Cloud, Video } from "lucide-react"
+import type { SharePlatformConfig } from "./types"
+
+export const SHARE_PLATFORMS: SharePlatformConfig[] = [
+ {
+ id: "twitter",
+ label: "X",
+ accent: "from-[#000000] to-[#1a1a1a] text-white",
+ description: "280 characters",
+ icon:
+ },
+ {
+ id: "whatsapp",
+ label: "WhatsApp",
+ accent: "from-[#25D366] to-[#1EBE57] text-white",
+ description: "Share via chat",
+ icon:
+ },
+ {
+ id: "linkedin",
+ label: "LinkedIn",
+ accent: "from-[#0A66C2] to-[#004182] text-white",
+ description: "Professional",
+ icon:
+ },
+ {
+ id: "telegram",
+ label: "Telegram",
+ accent: "from-[#0088cc] to-[#006699] text-white",
+ description: "Instant messaging",
+ icon:
+ },
+ {
+ id: "facebook",
+ label: "Facebook",
+ accent: "from-[#1877F2] to-[#0F5EC7] text-white",
+ description: "Social network",
+ icon:
+ },
+ {
+ id: "email",
+ label: "Email",
+ accent: "from-[#EA4335] to-[#C5221F] text-white",
+ description: "Send via email",
+ icon:
+ },
+ {
+ id: "threads",
+ label: "Threads",
+ accent: "from-[#000000] to-[#1a1a1a] text-white",
+ description: "500 characters",
+ icon:
+ },
+ {
+ id: "bluesky",
+ label: "Bluesky",
+ accent: "from-[#0085ff] to-[#0062bd] text-white",
+ description: "300 characters",
+ icon:
+ },
+ {
+ id: "tiktok",
+ label: "TikTok",
+ accent: "from-[#000000] to-[#1a1a1a] text-white",
+ description: "2200 characters",
+ icon:
+ }
+]
+
+export interface SuggestionTile {
+ title: string
+ description: string
+}
+
+export const SUGGESTED_QUESTIONS: SuggestionTile[] = [
+ {
+ title: "Latest developments in Kenyan constitutional law",
+ description: "Get a rapid scan of amendments, rulings, and reforms."
+ },
+ {
+ title: "Recent changes to the Kenyan Penal Code",
+ description: "Understand how new provisions impact compliance."
+ },
+ {
+ title: "Key provisions of the Competition Act",
+ description: "Summaries on enforcement thresholds and penalties."
+ },
+ {
+ title: "Environmental law cases in Kenyan courts",
+ description: "Explore how judges interpret conservation mandates."
+ },
+ {
+ title: "Requirements for starting a business in Kenya",
+ description: "Licensing, compliance, and registration checklist."
+ }
+]
+
+export const RESEARCH_SUGGESTED_QUESTIONS: SuggestionTile[] = [
+ {
+ title: "Comprehensive analysis of the Bill of Rights",
+ description: "Focus on digital rights and emerging jurisprudence."
+ },
+ {
+ title: "Evolution of environmental law in Kenya",
+ description: "Trace policy effectiveness against conservation goals."
+ },
+ {
+ title: "Impact of Penal Code amendments on cybercrime",
+ description: "Deep dive into enforcement trends and loopholes."
+ },
+ {
+ title: "Devolution framework in the Constitution",
+ description: "Assess implementation challenges across counties."
+ },
+ {
+ title: "Data protection and privacy rights landscape",
+ description: "Map compliance expectations to ICT deployments."
+ }
+]
diff --git a/src/components/chat/index.ts b/src/components/chat/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8b91b78d1f22066570f546738cf8a1b46a0f60a6
--- /dev/null
+++ b/src/components/chat/index.ts
@@ -0,0 +1,13 @@
+export * from './types'
+export * from './constants'
+export * from './ChatSidebar'
+export * from './ChatHeader'
+export * from './ChatInput'
+export * from './AmaniMessageList'
+export * from './AmaniChat'
+export * from './WelcomeScreen'
+export * from './FileUpload'
+export * from './ImagePreview'
+export * from './AudioPreview'
+export * from './VideoPreview'
+export * from './media-api'
diff --git a/src/components/chat/media-api.ts b/src/components/chat/media-api.ts
new file mode 100644
index 0000000000000000000000000000000000000000..aa941c2682ae289be5435eea7b070785e14d8646
--- /dev/null
+++ b/src/components/chat/media-api.ts
@@ -0,0 +1,401 @@
+/**
+ * Media API Client
+ * Handles multimodal media uploads and asset management
+ * Integrates with /api/v1/media/* endpoints
+ */
+
+import type { MediaFileType, MediaAsset, MediaMetadata, MediaProcessingStatus } from "./types"
+
+const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
+
+// ============================================================
+// Types (re-export from types.ts for convenience)
+// ============================================================
+
+// Re-export types that consumers might need
+export type { MediaFileType, MediaAsset, MediaMetadata, MediaProcessingStatus }
+
+// Additional API-specific types
+export type ProcessingStatus = MediaProcessingStatus
+
+export interface MediaUploadResponse {
+ asset_id: string
+ session_id: string
+ file_type: MediaFileType
+ filename: string
+ status: ProcessingStatus
+ cloudinary_url?: string
+ processing_time_ms?: number
+ transcript?: string
+ frame_urls?: string[]
+ embedding_dimensions?: number
+ metadata?: MediaMetadata
+}
+
+export interface ProcessingStatusResponse {
+ asset_id: string
+ status: ProcessingStatus
+ progress_percent?: number
+ current_step?: string
+ error_message?: string
+}
+
+export interface MediaQueryResponse {
+ response: string
+ sources?: Array<{
+ asset_id: string
+ relevance_score: number
+ excerpt?: string
+ }>
+ processing_time_ms: number
+}
+
+// ============================================================
+// Helper Functions
+// ============================================================
+
+function getAuthHeaders(): Record {
+ const headers: Record = {}
+
+ if (typeof window !== "undefined") {
+ const sessionToken = localStorage.getItem("session_token")
+ if (sessionToken) {
+ headers["X-Session-Token"] = sessionToken
+ }
+ }
+
+ return headers
+}
+
+function detectFileType(file: File): MediaFileType {
+ const mimeType = file.type.toLowerCase()
+
+ if (mimeType.startsWith("image/")) return "image"
+ if (mimeType === "application/pdf") return "pdf"
+ if (mimeType.startsWith("audio/")) return "audio"
+ if (mimeType.startsWith("video/")) return "video"
+
+ // Fallback to extension check
+ const ext = file.name.split(".").pop()?.toLowerCase()
+ if (["jpg", "jpeg", "png", "gif", "webp", "bmp"].includes(ext || "")) return "image"
+ if (ext === "pdf") return "pdf"
+ if (["mp3", "wav", "m4a", "ogg", "flac", "aac"].includes(ext || "")) return "audio"
+ if (["mp4", "mov", "avi", "mkv", "webm", "m4v"].includes(ext || "")) return "video"
+
+ return "image" // Default fallback
+}
+
+// ============================================================
+// Media API Client Class
+// ============================================================
+
+export class MediaApiClient {
+ private baseURL: string
+
+ constructor(baseURL: string = API_BASE_URL) {
+ this.baseURL = baseURL
+ }
+
+ /**
+ * Upload a media file with automatic type detection
+ */
+ async upload(
+ file: File,
+ sessionId: string,
+ options?: {
+ onProgress?: (percent: number) => void
+ extractFrames?: boolean
+ transcribe?: boolean
+ }
+ ): Promise {
+ const formData = new FormData()
+ formData.append("file", file)
+ formData.append("session_id", sessionId)
+
+ if (options?.extractFrames !== undefined) {
+ formData.append("extract_frames", String(options.extractFrames))
+ }
+ if (options?.transcribe !== undefined) {
+ formData.append("transcribe", String(options.transcribe))
+ }
+
+ const response = await fetch(`${this.baseURL}/api/v1/media/upload`, {
+ method: "POST",
+ headers: getAuthHeaders(),
+ body: formData,
+ })
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: "Upload failed" }))
+ throw new Error(error.detail || `HTTP ${response.status}`)
+ }
+
+ return response.json()
+ }
+
+ /**
+ * Upload an image file
+ */
+ async uploadImage(file: File, sessionId: string): Promise {
+ const formData = new FormData()
+ formData.append("file", file)
+ formData.append("session_id", sessionId)
+
+ const response = await fetch(`${this.baseURL}/api/v1/media/upload/image`, {
+ method: "POST",
+ headers: getAuthHeaders(),
+ body: formData,
+ })
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: "Image upload failed" }))
+ throw new Error(error.detail || `HTTP ${response.status}`)
+ }
+
+ return response.json()
+ }
+
+ /**
+ * Upload a PDF file
+ */
+ async uploadPdf(file: File, sessionId: string): Promise {
+ const formData = new FormData()
+ formData.append("file", file)
+ formData.append("session_id", sessionId)
+
+ const response = await fetch(`${this.baseURL}/api/v1/media/upload/pdf`, {
+ method: "POST",
+ headers: getAuthHeaders(),
+ body: formData,
+ })
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: "PDF upload failed" }))
+ throw new Error(error.detail || `HTTP ${response.status}`)
+ }
+
+ return response.json()
+ }
+
+ /**
+ * Upload an audio file
+ */
+ async uploadAudio(
+ file: File,
+ sessionId: string,
+ options?: { transcribe?: boolean }
+ ): Promise {
+ const formData = new FormData()
+ formData.append("file", file)
+ formData.append("session_id", sessionId)
+
+ if (options?.transcribe !== undefined) {
+ formData.append("transcribe", String(options.transcribe))
+ }
+
+ const response = await fetch(`${this.baseURL}/api/v1/media/upload/audio`, {
+ method: "POST",
+ headers: getAuthHeaders(),
+ body: formData,
+ })
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: "Audio upload failed" }))
+ throw new Error(error.detail || `HTTP ${response.status}`)
+ }
+
+ return response.json()
+ }
+
+ /**
+ * Upload a video file
+ */
+ async uploadVideo(
+ file: File,
+ sessionId: string,
+ options?: { extractFrames?: boolean; transcribeAudio?: boolean }
+ ): Promise {
+ const formData = new FormData()
+ formData.append("file", file)
+ formData.append("session_id", sessionId)
+
+ if (options?.extractFrames !== undefined) {
+ formData.append("extract_frames", String(options.extractFrames))
+ }
+ if (options?.transcribeAudio !== undefined) {
+ formData.append("transcribe_audio", String(options.transcribeAudio))
+ }
+
+ const response = await fetch(`${this.baseURL}/api/v1/media/upload/video`, {
+ method: "POST",
+ headers: getAuthHeaders(),
+ body: formData,
+ })
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: "Video upload failed" }))
+ throw new Error(error.detail || `HTTP ${response.status}`)
+ }
+
+ return response.json()
+ }
+
+ /**
+ * Get all assets for a session
+ */
+ async getSessionAssets(sessionId: string): Promise {
+ const response = await fetch(
+ `${this.baseURL}/api/v1/media/sessions/${sessionId}/assets`,
+ {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ ...getAuthHeaders(),
+ },
+ }
+ )
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: "Failed to get assets" }))
+ throw new Error(error.detail || `HTTP ${response.status}`)
+ }
+
+ return response.json()
+ }
+
+ /**
+ * Get a specific asset by ID
+ */
+ async getAsset(assetId: string): Promise {
+ const response = await fetch(`${this.baseURL}/api/v1/media/assets/${assetId}`, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ ...getAuthHeaders(),
+ },
+ })
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: "Failed to get asset" }))
+ throw new Error(error.detail || `HTTP ${response.status}`)
+ }
+
+ return response.json()
+ }
+
+ /**
+ * Re-process an asset (e.g., re-run transcription)
+ */
+ async reprocessAsset(assetId: string): Promise {
+ const response = await fetch(`${this.baseURL}/api/v1/media/process/${assetId}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...getAuthHeaders(),
+ },
+ })
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: "Failed to reprocess asset" }))
+ throw new Error(error.detail || `HTTP ${response.status}`)
+ }
+
+ return response.json()
+ }
+
+ /**
+ * Delete an asset
+ */
+ async deleteAsset(assetId: string): Promise {
+ const response = await fetch(`${this.baseURL}/api/v1/media/assets/${assetId}`, {
+ method: "DELETE",
+ headers: {
+ "Content-Type": "application/json",
+ ...getAuthHeaders(),
+ },
+ })
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: "Failed to delete asset" }))
+ throw new Error(error.detail || `HTTP ${response.status}`)
+ }
+ }
+}
+
+// ============================================================
+// Singleton Instance
+// ============================================================
+
+export const mediaApi = new MediaApiClient()
+
+// ============================================================
+// Utility Functions
+// ============================================================
+
+/**
+ * Check if a file type is supported for media upload
+ */
+export function isSupportedMediaType(file: File): boolean {
+ const type = detectFileType(file)
+ return ["image", "pdf", "audio", "video"].includes(type)
+}
+
+/**
+ * Get file type from File object
+ */
+export function getMediaFileType(file: File): MediaFileType {
+ return detectFileType(file)
+}
+
+/**
+ * Get human-readable file type label
+ */
+export function getFileTypeLabel(type: MediaFileType): string {
+ const labels: Record = {
+ image: "Image",
+ pdf: "PDF Document",
+ audio: "Audio",
+ video: "Video",
+ }
+ return labels[type]
+}
+
+/**
+ * Get accepted file extensions for input element
+ */
+export function getAcceptedMediaExtensions(): string {
+ return [
+ // Images
+ ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp",
+ // PDFs
+ ".pdf",
+ // Audio
+ ".mp3", ".wav", ".m4a", ".ogg", ".flac", ".aac",
+ // Video
+ ".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v",
+ ].join(",")
+}
+
+/**
+ * Format file size for display
+ */
+export function formatFileSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
+}
+
+/**
+ * Format duration in seconds to mm:ss or hh:mm:ss
+ */
+export function formatDuration(seconds: number): string {
+ const hrs = Math.floor(seconds / 3600)
+ const mins = Math.floor((seconds % 3600) / 60)
+ const secs = Math.floor(seconds % 60)
+
+ if (hrs > 0) {
+ return `${hrs}:${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`
+ }
+ return `${mins}:${secs.toString().padStart(2, "0")}`
+}
diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2cda76bc1defbc26c92ff3b778c52f307dff639b
--- /dev/null
+++ b/src/components/chat/types.ts
@@ -0,0 +1,183 @@
+import type { ReactNode } from "react"
+import type { AmaniQueryResponse as StructuredResponse } from "../AmaniQueryResponse"
+
+export type { StructuredResponse }
+
+export interface WidgetInput {
+ name: string
+ label: string
+ type: string
+ placeholder?: string
+ default_value?: string
+}
+
+export interface WidgetOutput {
+ label: string
+ format: string
+}
+
+export interface InteractiveWidget {
+ type: string
+ title: string
+ description: string
+ formula: string
+ inputs: WidgetInput[]
+ outputs: WidgetOutput[]
+ source_citation?: string
+}
+
+export interface GithubDiff {
+ old_text: string
+ new_text: string
+ title: string
+ highlight_type: "side_by_side" | "unified"
+}
+
+export interface Message {
+ id: string
+ session_id: string
+ role: "user" | "assistant"
+ content: string
+ created_at: string
+ token_count?: number
+ model_used?: string
+ sources?: Source[]
+ attachments?: Attachment[]
+ feedback_type?: "like" | "dislike"
+ saved?: boolean
+ failed?: boolean
+ originalQuery?: string
+ isEditing?: boolean
+ isRegenerating?: boolean
+ // AK-RAG structured response support
+ structured_response?: StructuredResponse
+ interactive_widgets?: InteractiveWidget[]
+ github_diff?: GithubDiff
+ persona?: "wanjiku" | "wakili" | "mwanahabari"
+}
+
+export interface Source {
+ title: string
+ url: string
+ source_name: string
+ category: string
+ excerpt: string
+}
+
+export interface Attachment {
+ id: string
+ filename: string
+ file_type: string
+ file_size: number
+ uploaded_at: string
+ processed: boolean
+ cloudinary_url?: string
+ // Media-specific fields
+ transcript?: string
+ frame_urls?: string[]
+ duration_seconds?: number
+ metadata?: {
+ width?: number
+ height?: number
+ mime_type?: string
+ }
+}
+
+export interface ChatSession {
+ id: string
+ title: string
+ message_count: number
+ created_at: string
+ updated_at: string
+}
+
+export interface StreamMetadata {
+ token_count?: number
+ model_used?: string
+ sources?: Source[]
+}
+
+export type SharePlatform = "twitter" | "linkedin" | "facebook" | "whatsapp" | "telegram" | "email" | "threads" | "bluesky" | "tiktok"
+
+export interface ShareFormatResponse {
+ platform: SharePlatform
+ content: string | string[]
+ character_count?: number
+ hashtags?: string[]
+ metadata?: Record
+}
+
+export interface ShareSheetState {
+ messageId: string
+ platform: SharePlatform
+ preview?: ShareFormatResponse | null
+ isLoading: boolean
+ shareLink?: string | null
+ shareLinkLoading?: boolean
+ posting?: boolean
+ generatingImage?: boolean
+ shareError?: string | null
+ success?: string | null
+}
+
+export interface SharePlatformConfig {
+ id: SharePlatform
+ label: string
+ accent: string
+ description: string
+ icon: ReactNode
+}
+
+// ============================================================
+// Media Types (for multimodal RAG)
+// ============================================================
+
+export type MediaFileType = "image" | "pdf" | "audio" | "video"
+export type MediaProcessingStatus = "pending" | "processing" | "completed" | "failed"
+
+export interface MediaAsset {
+ id: string
+ session_id: string
+ user_id?: string
+ file_type: MediaFileType
+ original_filename: string
+ storage_path?: string
+ cloudinary_url?: string
+ transcript?: string
+ metadata?: MediaMetadata
+ processed: boolean
+ created_at: string
+}
+
+export interface MediaMetadata {
+ duration_seconds?: number
+ frame_count?: number
+ dimensions?: { width: number; height: number }
+ audio_format?: string
+ video_format?: string
+ file_size_bytes?: number
+ mime_type?: string
+}
+
+export interface MediaUploadResult {
+ asset_id: string
+ session_id: string
+ file_type: MediaFileType
+ filename: string
+ status: MediaProcessingStatus
+ cloudinary_url?: string
+ processing_time_ms?: number
+ transcript?: string
+ frame_urls?: string[]
+ embedding_dimensions?: number
+ metadata?: MediaMetadata
+}
+
+export interface ProcessingProgress {
+ asset_id: string
+ status: MediaProcessingStatus
+ progress_percent?: number
+ current_step?: string
+ error_message?: string
+}
+
diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..84454c09cfe71781696b614ed735202c4c9d6221
--- /dev/null
+++ b/src/components/theme-provider.tsx
@@ -0,0 +1,91 @@
+"use client"
+
+import * as React from "react"
+
+type Theme = "judicial-suede" | "savanna-tech"
+type Mode = "light" | "dark"
+
+type ThemeProviderProps = {
+ children: React.ReactNode
+ defaultTheme?: Theme
+ defaultMode?: Mode
+ storageKey?: string
+}
+
+type ThemeProviderState = {
+ theme: Theme
+ mode: Mode
+ setTheme: (theme: Theme) => void
+ setMode: (mode: Mode) => void
+}
+
+const initialState: ThemeProviderState = {
+ theme: "judicial-suede",
+ mode: "light",
+ setTheme: () => null,
+ setMode: () => null,
+}
+
+const ThemeProviderContext = React.createContext(initialState)
+
+export function ThemeProvider({
+ children,
+ defaultTheme = "judicial-suede",
+ defaultMode = "light",
+ storageKey = "ui-theme",
+ ...props
+}: ThemeProviderProps) {
+ const [theme, setTheme] = React.useState(defaultTheme)
+ const [mode, setMode] = React.useState(defaultMode)
+
+ React.useEffect(() => {
+ const storedTheme = localStorage.getItem(`${storageKey}-theme`) as Theme
+ const storedMode = localStorage.getItem(`${storageKey}-mode`) as Mode
+
+ if (storedTheme) setTheme(storedTheme)
+ if (storedMode) setMode(storedMode)
+ }, [storageKey])
+
+ React.useEffect(() => {
+ const root = window.document.documentElement
+
+ root.removeAttribute("data-theme")
+ root.classList.remove("dark")
+
+ if (theme !== "judicial-suede") {
+ root.setAttribute("data-theme", theme)
+ }
+
+ if (mode === "dark") {
+ root.classList.add("dark")
+ }
+ }, [theme, mode])
+
+ const value = {
+ theme,
+ mode,
+ setTheme: (theme: Theme) => {
+ localStorage.setItem(`${storageKey}-theme`, theme)
+ setTheme(theme)
+ },
+ setMode: (mode: Mode) => {
+ localStorage.setItem(`${storageKey}-mode`, mode)
+ setMode(mode)
+ },
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export const useTheme = () => {
+ const context = React.useContext(ThemeProviderContext)
+
+ if (context === undefined)
+ throw new Error("useTheme must be used within a ThemeProvider")
+
+ return context
+}
\ No newline at end of file
diff --git a/src/components/theme-toggle.tsx b/src/components/theme-toggle.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..927a3c870bd0abad221da64e9a0ac8dd8808f227
--- /dev/null
+++ b/src/components/theme-toggle.tsx
@@ -0,0 +1,47 @@
+"use client"
+
+import * as React from "react"
+import { Moon, Sun, Palette } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { useTheme } from "@/components/theme-provider"
+
+export function ThemeToggle() {
+ const { setTheme, setMode } = useTheme()
+
+ return (
+
+
+
+
+
+ Toggle theme
+
+
+
+ setMode("light")}>
+
+ Light
+
+ setMode("dark")}>
+
+ Dark
+
+ setTheme("judicial-suede")}>
+
+ Judicial Suede
+
+ setTheme("savanna-tech")}>
+
+ Savanna Tech
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/transcript-display.tsx b/src/components/transcript-display.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b83f9ed3357997aed1a9b7e78a0afe4eb360016c
--- /dev/null
+++ b/src/components/transcript-display.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+import { useEffect, useRef } from "react"
+import { cn } from "@/lib/utils"
+import { User, Bot } from "lucide-react"
+
+export interface TranscriptMessage {
+ id: string
+ role: "user" | "agent"
+ content: string
+ timestamp: Date
+ isStreaming?: boolean
+}
+
+interface TranscriptDisplayProps {
+ messages: TranscriptMessage[]
+ className?: string
+}
+
+export function TranscriptDisplay({ messages, className }: TranscriptDisplayProps) {
+ const scrollRef = useRef(null)
+
+ useEffect(() => {
+ // Auto-scroll to bottom when new messages arrive
+ if (scrollRef.current) {
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight
+ }
+ }, [messages])
+
+ const formatTime = (date: Date) => {
+ return new Intl.DateTimeFormat("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ }).format(date)
+ }
+
+ return (
+
+ {messages.length === 0 ? (
+
+
+
Start speaking to see the conversation transcript
+
Your words will appear here in real-time
+
+
+ ) : (
+ messages.map((message) => (
+
+ {message.role === "agent" && (
+
+
+
+ )}
+
+
+
+
+
+ {message.content}
+ {message.isStreaming && (
+
+ )}
+
+
+
+
+ {formatTime(message.timestamp)}
+
+
+
+ {message.role === "user" && (
+
+
+
+ )}
+
+ ))
+ )}
+
+ )
+}
+
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..84c2f61d3f140070eda038e57ee40988c18822c8
--- /dev/null
+++ b/src/components/ui/badge.tsx
@@ -0,0 +1,46 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center justify-center rounded-full 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",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "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",
+ outline:
+ "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span"
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..80a85de497c20f119e785c3c4372429f9c3dce52
--- /dev/null
+++ b/src/components/ui/button.tsx
@@ -0,0 +1,60 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 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",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost:
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot : "button"
+
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b24396425125f15201f8f293739e2a294c83c60f
--- /dev/null
+++ b/src/components/ui/card.tsx
@@ -0,0 +1,92 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..bd6db3037f3df6cdde2ce58c03a53dcdf39e6a4d
--- /dev/null
+++ b/src/components/ui/checkbox.tsx
@@ -0,0 +1,30 @@
+"use client"
+
+import * as React from "react"
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+import { Check } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Checkbox = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+))
+Checkbox.displayName = CheckboxPrimitive.Root.displayName
+
+export { Checkbox }
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..346f1c95260380b687edad22999c2d16bc86801c
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,122 @@
+"use client"
+
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Dialog = DialogPrimitive.Root
+
+const DialogTrigger = DialogPrimitive.Trigger
+
+const DialogPortal = DialogPrimitive.Portal
+
+const DialogClose = DialogPrimitive.Close
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+))
+DialogContent.displayName = DialogPrimitive.Content.displayName
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+DialogHeader.displayName = "DialogHeader"
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+DialogFooter.displayName = "DialogFooter"
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogTitle.displayName = DialogPrimitive.Title.displayName
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogDescription.displayName = DialogPrimitive.Description.displayName
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+}
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..04888b7e6db7fd14efdcb51db27b6e0499c9f2fd
--- /dev/null
+++ b/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,257 @@
+"use client"
+
+import * as React from "react"
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
+import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function DropdownMenu({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuContent({
+ className,
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function DropdownMenuGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..6a72603669227105356cbaa297485c8d52f7d64e
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,21 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..a0a404d38376e392484ae536b1389b314c268765
--- /dev/null
+++ b/src/components/ui/label.tsx
@@ -0,0 +1,26 @@
+"use client"
+
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const labelVariants = cva(
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+)
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
diff --git a/src/components/ui/otp-input.tsx b/src/components/ui/otp-input.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..42c218bbd0400778ffa4603ce912fa81dea5a57f
--- /dev/null
+++ b/src/components/ui/otp-input.tsx
@@ -0,0 +1,125 @@
+"use client"
+
+import { useState, useRef, useEffect } from "react"
+import { Input } from "./input"
+
+interface OTPInputProps {
+ length?: number
+ value: string
+ onChange: (value: string) => void
+ onComplete?: (value: string) => void
+ disabled?: boolean
+ className?: string
+}
+
+export function OTPInput({
+ length = 6,
+ value,
+ onChange,
+ onComplete,
+ disabled = false,
+ className = ""
+}: OTPInputProps) {
+ const [otp, setOtp] = useState(new Array(length).fill(""))
+ const inputRefs = useRef<(HTMLInputElement | null)[]>([])
+ const prevValueRef = useRef(value)
+
+ useEffect(() => {
+ // Only update if value actually changed to avoid cascading renders
+ if (prevValueRef.current !== value) {
+ prevValueRef.current = value
+
+ // Defer state update to next tick to avoid synchronous setState in effect
+ queueMicrotask(() => {
+ if (value && value.length === length) {
+ setOtp(value.split(""))
+ } else if (!value) {
+ setOtp(new Array(length).fill(""))
+ }
+ })
+ }
+ }, [value, length])
+
+ const handleChange = (index: number, val: string) => {
+ if (disabled) return
+
+ // Only allow digits
+ const digit = val.replace(/\D/g, "")
+ if (digit.length > 1) return
+
+ const newOtp = [...otp]
+ newOtp[index] = digit
+ setOtp(newOtp)
+
+ const otpString = newOtp.join("")
+ onChange(otpString)
+
+ // Auto-focus next input
+ if (digit && index < length - 1) {
+ inputRefs.current[index + 1]?.focus()
+ }
+
+ // Call onComplete when all digits are filled
+ if (otpString.length === length && onComplete) {
+ onComplete(otpString)
+ }
+ }
+
+ const handleKeyDown = (index: number, e: React.KeyboardEvent