feat: migrate Leptos frontend to Next.js 16 (React 19)
Deploy to VPS / deploy (push) Failing after 42s
Deploy to VPS / deploy (push) Failing after 42s
Complete migration from services/frontend.old/ (Leptos 0.7 WASM + Rust) to services/frontend/ (Next.js 16 static export + TypeScript + Tailwind v4). Summary: - Port all shared types (message, guild, voice, media, dashboard, recording, ui) - Build fetch-based API client covering all 30+ backend endpoints - WebSocket client with auto-reconnect (exponential backoff, 20 attempts) - React context provider for WS with typed event subscription (22 event types) - Login page with localStorage auth + auto-redirect - Dashboard layout with sidebar, header (WS status + theme toggle) - Messages: feed, search, images tab, review tab, channel filter, detail modal - Live: voice connection, music player, recordings, mic transmit, active speakers - Dashboard: stats, user list, channel list, detail views - Mascot chatbot with history + clear - uiStateApi persistence for selected tab - Add static export config, update deploy scripts and CI
This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
# API/WS endpoints — set these before building
|
||||
VITE_BE_API_URL=http://localhost:3001
|
||||
VITE_BE_WS_URL=ws://localhost:3001/ws
|
||||
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
Generated
-2557
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["shared-types", "frontend"]
|
||||
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": ["**", "!node_modules", "!.next", "!dist", "!build"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noUnknownAtRules": "off",
|
||||
"useIterableCallbackReturn": "off",
|
||||
"noArrayIndexKey": "warn"
|
||||
},
|
||||
"a11y": {
|
||||
"useButtonType": "off",
|
||||
"noAutofocus": "off"
|
||||
},
|
||||
"performance": {
|
||||
"noImgElement": "warn"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "warn"
|
||||
},
|
||||
"correctness": {
|
||||
"noInvalidUseBeforeDeclaration": "off",
|
||||
"noUnusedFunctionParameters": "warn"
|
||||
}
|
||||
},
|
||||
"domains": {
|
||||
"next": "recommended",
|
||||
"react": "recommended"
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,881 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.27.0",
|
||||
"next": "16.2.12",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"shadcn": "^4.15.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"sharp",
|
||||
],
|
||||
"packages": {
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
|
||||
|
||||
"@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
|
||||
|
||||
"@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
|
||||
|
||||
"@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
|
||||
|
||||
"@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
|
||||
|
||||
"@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="],
|
||||
|
||||
"@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||
|
||||
"@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="],
|
||||
|
||||
"@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA=="],
|
||||
|
||||
"@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="],
|
||||
|
||||
"@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-syntax-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw=="],
|
||||
|
||||
"@babel/preset-typescript": ["@babel/preset-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/plugin-transform-modules-commonjs": "^7.29.7", "@babel/plugin-transform-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@base-ui/react": ["@base-ui/react@1.6.0", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@base-ui/utils": "0.3.1", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw=="],
|
||||
|
||||
"@base-ui/utils": ["@base-ui/utils@0.3.1", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg=="],
|
||||
|
||||
"@biomejs/biome": ["@biomejs/biome@2.2.0", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.0", "@biomejs/cli-darwin-x64": "2.2.0", "@biomejs/cli-linux-arm64": "2.2.0", "@biomejs/cli-linux-arm64-musl": "2.2.0", "@biomejs/cli-linux-x64": "2.2.0", "@biomejs/cli-linux-x64-musl": "2.2.0", "@biomejs/cli-win32-arm64": "2.2.0", "@biomejs/cli-win32-x64": "2.2.0" }, "bin": { "biome": "bin/biome" } }, "sha512-3On3RSYLsX+n9KnoSgfoYlckYBoU6VRM22cw1gB4Y0OuUVSYd/O/2saOJMrA4HFfA1Ff0eacOvMN1yAAvHtzIw=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zKbwUUh+9uFmWfS8IFxmVD6XwqFcENjZvEyfOxHs1epjdH3wyyMQG80FGDsmauPwS2r5kXdEM0v/+dTIA9FXAg=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-+OmT4dsX2eTfhD5crUOPw3RPhaR+SKVspvGVmSdZ9y9O/AgL8pla6T4hOn1q+VAFBHuHhsdxDRJgFCSC7RaMOw=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-6eoRdF2yW5FnW9Lpeivh7Mayhq0KDdaDMYOJnH9aT02KuSIX5V1HmWJCQQPwIQbhDh68Zrcpl8inRlTEan0SXw=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-egKpOa+4FL9YO+SMUMLUvf543cprjevNc3CAgDNFLcjknuNMcZ0GLJYa3EGTCR2xIkIUJDVneBV3O9OcIlCEZQ=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5UmQx/OZAfJfi25zAnAGHUMuOd+LOsliIt119x2soA2gLggQYrVPA+2kMUxR6Mw5M1deUF/AWWP2qpxgH7Nyfw=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-I5J85yWwUWpgJyC1CcytNSGusu2p9HjDnOPAFG4Y515hwRD0jpR9sT9/T1cKHtuCvEQ/sBvx+6zhz9l9wEJGAg=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-n9a1/f2CwIDmNMNkFs+JI0ZjFnMO0jdOyGNtihgUNFnlmd84yIYY2KMTBmMV58ZlVHjgmY5Y6E1hVTnSRieggA=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Nawu5nHjP/zPKTIryh2AavzTc/KEg4um/MxWdXW0A6P/RZOyIpa7+QSjeXwAwX/utJGaCoXRPWtF3m5U/bB3Ww=="],
|
||||
|
||||
"@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.75.1", "", { "dependencies": { "@dotenvx/primitives": "^0.8.0", "commander": "^11.1.0", "conf": "^10.2.0", "dotenv": "^17.2.1", "enquirer": "^2.4.1", "env-paths": "^2.2.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "open": "^8.4.2", "picomatch": "^4.0.4", "systeminformation": "^5.22.11", "undici": "^7.11.0", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ=="],
|
||||
|
||||
"@dotenvx/primitives": ["@dotenvx/primitives@0.8.0", "", {}, "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
|
||||
|
||||
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
|
||||
"@next/env": ["@next/env@16.2.12", "", {}, "sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
|
||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||
|
||||
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
|
||||
|
||||
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
|
||||
|
||||
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.3", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "postcss": "^8.5.16", "tailwindcss": "4.3.3" } }, "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg=="],
|
||||
|
||||
"@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
|
||||
|
||||
"@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
|
||||
|
||||
"atomically": ["atomically@1.7.0", "", {}, "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w=="],
|
||||
|
||||
"babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.3", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-sbT0Ui/CZwyAyy7icT1Gw5P1LKRlFaHwaF6tDCW5YHq2X5SeeZFphBuIagopSfwSSZq3sQcbmEL072yphxm7ew=="],
|
||||
|
||||
"body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="],
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
|
||||
|
||||
"cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="],
|
||||
|
||||
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
|
||||
"conf": ["conf@10.2.0", "", { "dependencies": { "ajv": "^8.6.3", "ajv-formats": "^2.1.1", "atomically": "^1.7.0", "debounce-fn": "^4.0.0", "dot-prop": "^6.0.1", "env-paths": "^2.2.1", "json-schema-typed": "^7.0.3", "onetime": "^5.1.2", "pkg-up": "^3.1.0", "semver": "^7.3.5" } }, "sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"debounce-fn": ["debounce-fn@4.0.0", "", { "dependencies": { "mimic-fn": "^3.0.0" } }, "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="],
|
||||
|
||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||
|
||||
"default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="],
|
||||
|
||||
"default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="],
|
||||
|
||||
"define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
||||
|
||||
"dot-prop": ["dot-prop@6.0.1", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="],
|
||||
|
||||
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.396", "", {}, "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.24.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ=="],
|
||||
|
||||
"enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="],
|
||||
|
||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
|
||||
|
||||
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
|
||||
|
||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.6.0", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="],
|
||||
|
||||
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"fs-extra": ["fs-extra@11.4.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "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" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-own-enumerable-keys": ["get-own-enumerable-keys@1.0.0", "", {}, "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
|
||||
|
||||
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||
|
||||
"hono": ["hono@4.12.32", "", {}, "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ip-address": ["ip-address@10.3.1", "", {}, "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
|
||||
|
||||
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="],
|
||||
|
||||
"is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
|
||||
|
||||
"is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="],
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"is-obj": ["is-obj@3.0.0", "", {}, "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="],
|
||||
|
||||
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
|
||||
|
||||
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
|
||||
|
||||
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
||||
|
||||
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||
|
||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"jose": ["jose@6.2.4", "", {}, "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
|
||||
"locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="],
|
||||
|
||||
"log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.27.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
||||
|
||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"mimic-fn": ["mimic-fn@3.1.0", "", {}, "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ=="],
|
||||
|
||||
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||
|
||||
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"next": ["next@16.2.12", "", { "dependencies": { "@next/env": "16.2.12", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.12", "@next/swc-darwin-x64": "16.2.12", "@next/swc-linux-arm64-gnu": "16.2.12", "@next/swc-linux-arm64-musl": "16.2.12", "@next/swc-linux-x64-gnu": "16.2.12", "@next/swc-linux-x64-musl": "16.2.12", "@next/swc-win32-arm64-msvc": "16.2.12", "@next/swc-win32-x64-msvc": "16.2.12", "sharp": "^0.34.5" }, "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" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="],
|
||||
|
||||
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
|
||||
|
||||
"ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="],
|
||||
|
||||
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||
|
||||
"p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
|
||||
|
||||
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"parse-json": ["parse-json@5.2.0", "", { "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" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
|
||||
|
||||
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||
|
||||
"path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
|
||||
|
||||
"postcss-selector-parser": ["postcss-selector-parser@7.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg=="],
|
||||
|
||||
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
|
||||
|
||||
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
|
||||
|
||||
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="],
|
||||
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||
|
||||
"recast": ["recast@0.23.12", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
|
||||
|
||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"shadcn": ["shadcn@4.15.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "kleur": "^4.1.5", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "undici": "^7.27.2", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-fFTpfOuRwqjpXGp/sKpAxJkjdgv1jf8bDrW1xi0cVn2k7WJ5ijV/gjkAlkAidGDjhEkgcUuxVdm4oRB9r8BivA=="],
|
||||
|
||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "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" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
|
||||
|
||||
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
|
||||
"stringify-object": ["stringify-object@5.0.0", "", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
|
||||
|
||||
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
|
||||
|
||||
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "@babel/core": "*", "babel-plugin-macros": "*", "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "optionalPeers": ["@babel/core", "babel-plugin-macros"] }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
|
||||
"systeminformation": ["systeminformation@5.33.1", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="],
|
||||
|
||||
"tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
|
||||
|
||||
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"yocto-spinner": ["yocto-spinner@1.2.2", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng=="],
|
||||
|
||||
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
||||
|
||||
"@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
||||
|
||||
"@dotenvx/dotenvx/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"conf/ajv-formats": ["ajv-formats@2.1.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA=="],
|
||||
|
||||
"conf/json-schema-typed": ["json-schema-typed@7.0.3", "", {}, "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A=="],
|
||||
|
||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="],
|
||||
|
||||
"enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
|
||||
"log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
|
||||
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
|
||||
|
||||
"restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||
|
||||
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
|
||||
|
||||
"@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
||||
|
||||
"@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
||||
|
||||
"@dotenvx/dotenvx/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
"@dotenvx/dotenvx/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
|
||||
|
||||
"@dotenvx/dotenvx/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"@dotenvx/dotenvx/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
|
||||
|
||||
"@dotenvx/dotenvx/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
|
||||
|
||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
[package]
|
||||
name = "frontend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
leptos = { version = "=0.9.0-alpha", features = ["csr"] }
|
||||
leptos-use = "0.19"
|
||||
lucide-leptos = "3.23"
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = [
|
||||
"WebSocket",
|
||||
"MessageEvent",
|
||||
"CloseEvent",
|
||||
"ErrorEvent",
|
||||
"CanvasRenderingContext2d",
|
||||
"AudioContext",
|
||||
"AudioBuffer",
|
||||
"AudioBufferSourceNode",
|
||||
"AudioDestinationNode",
|
||||
"AudioNode",
|
||||
"AudioProcessingEvent",
|
||||
"MediaStreamAudioSourceNode",
|
||||
"ScriptProcessorNode",
|
||||
"Window",
|
||||
"Document",
|
||||
"Element",
|
||||
"HtmlElement",
|
||||
"HtmlSelectElement",
|
||||
"KeyboardEvent",
|
||||
"Storage",
|
||||
"IntersectionObserver",
|
||||
"ResizeObserver",
|
||||
"Url",
|
||||
"Headers",
|
||||
"Request",
|
||||
"RequestInit",
|
||||
"RequestMode",
|
||||
"Response",
|
||||
"HtmlInputElement",
|
||||
"HtmlAudioElement",
|
||||
"HtmlCanvasElement",
|
||||
"MediaDevices",
|
||||
"MediaStream",
|
||||
"MediaStreamConstraints",
|
||||
"MediaStreamTrack",
|
||||
"Navigator",
|
||||
"console",
|
||||
] }
|
||||
gloo-net = "0.6"
|
||||
gloo-timers = { version = "0.3", features = ["futures"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde-wasm-bindgen = "0.6"
|
||||
wasm-logger = "0.2"
|
||||
console_error_panic_hook = "0.1"
|
||||
regex = "1"
|
||||
shared-types = { path = "../shared-types" }
|
||||
@@ -1,7 +0,0 @@
|
||||
[build]
|
||||
target = "index.html"
|
||||
dist = "dist"
|
||||
|
||||
[serve]
|
||||
port = 8080
|
||||
open = false
|
||||
@@ -1,20 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#3b82f6" />
|
||||
<title>IMPHNEN -- Discord Moderation</title>
|
||||
<link data-trunk rel="rust" data-crate="frontend" data-wasm="frontend.wasm" />
|
||||
<link data-trunk rel="css" href="src/styles/bundle.css" />
|
||||
<link data-trunk rel="copy-dir" href="public/" />
|
||||
<!-- Inter + JetBrains Mono fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<!-- Preload WASM -->
|
||||
<link rel="preload" href="/frontend.wasm" as="fetch" crossorigin="anonymous" />
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
# Trunk copies this directory to dist/
|
||||
@@ -1,24 +0,0 @@
|
||||
use crate::api::client::{request, ApiError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::{log_error, log_info, log_warn, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LoginPayload {
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginResponse {
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
pub async fn login(password: &str) -> Result<bool, ApiError> {
|
||||
let payload = LoginPayload {
|
||||
password: password.to_string(),
|
||||
};
|
||||
let body = serde_json::to_string(&payload).unwrap();
|
||||
let resp: LoginResponse = request("POST", "/api/auth/login", Some(&body)).await?;
|
||||
Ok(resp.ok)
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
use serde::de::DeserializeOwned;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{Headers, Request, RequestInit, RequestMode, Response};
|
||||
use crate::{log_debug, log_error, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiError {
|
||||
pub message: String,
|
||||
pub status_code: u16,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ApiError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "API error {}: {}", self.status_code, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ApiError {}
|
||||
|
||||
fn get_base_url() -> String {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let location = window.location();
|
||||
let protocol = location.protocol().unwrap_or_else(|_| "http:".to_string());
|
||||
let protocol = protocol.trim_end_matches(':');
|
||||
let host = location
|
||||
.host()
|
||||
.unwrap_or_else(|_| "localhost:3001".to_string());
|
||||
format!("{}://{}", protocol, host)
|
||||
} else {
|
||||
"http://localhost:3001".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_auth_header() -> Option<String> {
|
||||
// Read password from sessionStorage
|
||||
let storage = web_sys::window()?.local_storage().ok()??;
|
||||
storage.get_item("admin-password").ok()?
|
||||
}
|
||||
|
||||
pub async fn request<T: DeserializeOwned>(
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Option<&str>,
|
||||
) -> Result<T, ApiError> {
|
||||
let url = format!("{}{}", get_base_url(), path);
|
||||
|
||||
let headers = Headers::new().map_err(|_| {
|
||||
let msg = "Failed to create headers";
|
||||
log_error!("API {} {} failed: {}", method, path, msg);
|
||||
ApiError {
|
||||
message: msg.to_string(),
|
||||
status_code: 0,
|
||||
}
|
||||
})?;
|
||||
|
||||
if let Some(password) = get_auth_header() {
|
||||
headers.set("X-Admin-Password", &password).ok();
|
||||
}
|
||||
|
||||
if body.is_some() {
|
||||
headers.set("Content-Type", "application/json").ok();
|
||||
}
|
||||
|
||||
log_debug!("{} {} ->", method, path);
|
||||
|
||||
let opts = RequestInit::new();
|
||||
opts.set_method(method);
|
||||
opts.set_headers(&headers);
|
||||
opts.set_mode(RequestMode::Cors);
|
||||
|
||||
if let Some(json_body) = body {
|
||||
opts.set_body(&JsValue::from_str(json_body));
|
||||
}
|
||||
|
||||
let request = Request::new_with_str_and_init(&url, &opts).map_err(|e| {
|
||||
let msg = format!("Failed to create request: {:?}", e);
|
||||
log_error!("API {} {} failed: {}", method, path, msg);
|
||||
ApiError {
|
||||
message: msg,
|
||||
status_code: 0,
|
||||
}
|
||||
})?;
|
||||
|
||||
let window = web_sys::window().ok_or_else(|| {
|
||||
let msg = "No window";
|
||||
log_error!("API {} {} failed: {}", method, path, msg);
|
||||
ApiError {
|
||||
message: msg.to_string(),
|
||||
status_code: 0,
|
||||
}
|
||||
})?;
|
||||
|
||||
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let msg = format!("Fetch failed: {:?}", e);
|
||||
log_error!("API {} {} failed: {}", method, path, msg);
|
||||
ApiError {
|
||||
message: msg,
|
||||
status_code: 0,
|
||||
}
|
||||
})?;
|
||||
|
||||
let response: Response = resp_value.dyn_into().map_err(|_| {
|
||||
let msg = "Invalid response";
|
||||
log_error!("API {} {} failed: {}", method, path, msg);
|
||||
ApiError {
|
||||
message: msg.to_string(),
|
||||
status_code: 0,
|
||||
}
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
if status >= 400 {
|
||||
let text = JsFuture::from(response.text().map_err(|_| {
|
||||
let msg = "Failed to read error body";
|
||||
log_error!("API {} {} failed: {}", method, path, msg);
|
||||
ApiError {
|
||||
message: msg.to_string(),
|
||||
status_code: status,
|
||||
}
|
||||
})?)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| v.as_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
log_error!("API {} {} failed: status={} {}", method, path, status, text);
|
||||
return Err(ApiError {
|
||||
message: text,
|
||||
status_code: status,
|
||||
});
|
||||
}
|
||||
|
||||
let text = JsFuture::from(response.text().map_err(|_| {
|
||||
let msg = "Failed to read response body";
|
||||
log_error!("API {} {} failed: {}", method, path, msg);
|
||||
ApiError {
|
||||
message: msg.to_string(),
|
||||
status_code: status,
|
||||
}
|
||||
})?)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
let msg = "Failed to await response";
|
||||
log_error!("API {} {} failed: {}", method, path, msg);
|
||||
ApiError {
|
||||
message: msg.to_string(),
|
||||
status_code: status,
|
||||
}
|
||||
})?
|
||||
.as_string()
|
||||
.ok_or_else(|| {
|
||||
let msg = "Response is not text";
|
||||
log_error!("API {} {} failed: {}", method, path, msg);
|
||||
ApiError {
|
||||
message: msg.to_string(),
|
||||
status_code: status,
|
||||
}
|
||||
})?;
|
||||
|
||||
log_debug!("{} {} <- {}", method, path, status);
|
||||
|
||||
serde_json::from_str(&text).map_err(|e| {
|
||||
let msg = format!(
|
||||
"JSON parse error: {} — body: {}",
|
||||
e,
|
||||
&text[..text.len().min(200)]
|
||||
);
|
||||
log_error!("API {} {} failed: {}", method, path, msg);
|
||||
ApiError {
|
||||
message: msg,
|
||||
status_code: status,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn request_no_body(method: &str, path: &str) -> Result<(), ApiError> {
|
||||
request::<serde_json::Value>(method, path, None).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
use crate::api::client::{request, ApiError};
|
||||
use serde::Deserialize;
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppConfigResponse {
|
||||
pub monitor_guild_id: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /api/config
|
||||
pub async fn get_config() -> Result<AppConfigResponse, ApiError> {
|
||||
request("GET", "/api/config", None).await
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
use crate::api::client::{request, ApiError};
|
||||
use shared_types::dashboard::*;
|
||||
use crate::{log_debug, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
/// GET /api/dashboard/stats
|
||||
pub async fn get_dashboard_stats() -> Result<DashboardStats, ApiError> {
|
||||
log_debug!("get_dashboard_stats");
|
||||
request("GET", "/api/dashboard/stats", None).await
|
||||
}
|
||||
|
||||
/// GET /api/dashboard/users?limit=&cursor=&search=
|
||||
pub async fn get_dashboard_users(
|
||||
limit: Option<u32>,
|
||||
cursor: Option<&str>,
|
||||
search: Option<&str>,
|
||||
) -> Result<PaginatedUsers, ApiError> {
|
||||
log_debug!("get_dashboard_users: limit={:?}, cursor={:?}, search={:?}", limit, cursor, search);
|
||||
let mut path = "/api/dashboard/users".to_string();
|
||||
let mut params = vec![];
|
||||
if let Some(l) = limit {
|
||||
params.push(format!("limit={}", l));
|
||||
}
|
||||
if let Some(c) = cursor {
|
||||
params.push(format!("cursor={}", c));
|
||||
}
|
||||
if let Some(s) = search {
|
||||
params.push(format!("search={}", s));
|
||||
}
|
||||
if !params.is_empty() {
|
||||
path.push_str(&format!("?{}", params.join("&")));
|
||||
}
|
||||
request("GET", &path, None).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct PaginatedUsers {
|
||||
pub data: Vec<DashboardUser>,
|
||||
#[serde(rename = "nextCursor")]
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /api/dashboard/users/{userId}
|
||||
pub async fn get_dashboard_user_detail(user_id: &str) -> Result<DashboardUserDetail, ApiError> {
|
||||
log_debug!("get_dashboard_user_detail: user_id={}", user_id);
|
||||
request("GET", &format!("/api/dashboard/users/{}", user_id), None).await
|
||||
}
|
||||
|
||||
/// GET /api/dashboard/channels?limit=&cursor=&search=&guild_id=
|
||||
pub async fn get_dashboard_channels(
|
||||
limit: Option<u32>,
|
||||
cursor: Option<&str>,
|
||||
search: Option<&str>,
|
||||
guild_id: Option<&str>,
|
||||
) -> Result<PaginatedChannels, ApiError> {
|
||||
log_debug!("get_dashboard_channels: limit={:?}, cursor={:?}, search={:?}, guild_id={:?}", limit, cursor, search, guild_id);
|
||||
let mut path = "/api/dashboard/channels".to_string();
|
||||
let mut params = vec![];
|
||||
if let Some(l) = limit {
|
||||
params.push(format!("limit={}", l));
|
||||
}
|
||||
if let Some(c) = cursor {
|
||||
params.push(format!("cursor={}", c));
|
||||
}
|
||||
if let Some(s) = search {
|
||||
params.push(format!("search={}", s));
|
||||
}
|
||||
if let Some(g) = guild_id {
|
||||
params.push(format!("guild_id={}", g));
|
||||
}
|
||||
if !params.is_empty() {
|
||||
path.push_str(&format!("?{}", params.join("&")));
|
||||
}
|
||||
request("GET", &path, None).await
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct PaginatedChannels {
|
||||
pub data: Vec<DashboardChannel>,
|
||||
#[serde(rename = "nextCursor")]
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// GET /api/dashboard/channels/{channelId}
|
||||
pub async fn get_dashboard_channel_detail(
|
||||
channel_id: &str,
|
||||
) -> Result<DashboardChannelDetail, ApiError> {
|
||||
log_debug!("get_dashboard_channel_detail: channel_id={}", channel_id);
|
||||
request(
|
||||
"GET",
|
||||
&format!("/api/dashboard/channels/{}", channel_id),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
use crate::api::client::{request, ApiError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[derive(Debug, Serialize)]
|
||||
struct MascotChatRequest<'a> {
|
||||
message: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MascotChatResponse {
|
||||
pub response: String,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChatHistoryMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
pub async fn send_mascot_message(message: &str) -> Result<MascotChatResponse, ApiError> {
|
||||
let body = serde_json::to_string(&MascotChatRequest { message }).map_err(|err| ApiError {
|
||||
message: format!("Failed to serialize mascot request: {}", err),
|
||||
status_code: 0,
|
||||
})?;
|
||||
request("POST", "/api/mascot/chat", Some(&body)).await
|
||||
}
|
||||
|
||||
/// GET /api/mascot/chat/history
|
||||
pub async fn get_chat_history() -> Result<Vec<ChatHistoryMessage>, ApiError> {
|
||||
request("GET", "/api/mascot/chat/history", None).await
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
use crate::api::client::{request, ApiError};
|
||||
use shared_types::message::{MessageRecord, PageResult};
|
||||
use crate::{log_debug, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
/// GET /api/messages?guildId=&limit=&channelId=&cursor=
|
||||
pub async fn get_messages(
|
||||
guild_id: &str,
|
||||
limit: Option<u32>,
|
||||
channel_id: Option<&str>,
|
||||
cursor: Option<&str>,
|
||||
) -> Result<PageResult<MessageRecord>, ApiError> {
|
||||
log_debug!("get_messages: guild_id={}, limit={:?}, channel_id={:?}, cursor={:?}", guild_id, limit, channel_id, cursor);
|
||||
let mut path = format!("/api/messages?guildId={}", guild_id);
|
||||
if let Some(l) = limit {
|
||||
path.push_str(&format!("&limit={}", l));
|
||||
}
|
||||
if let Some(c) = channel_id {
|
||||
path.push_str(&format!("&channelId={}", c));
|
||||
}
|
||||
if let Some(c) = cursor {
|
||||
path.push_str(&format!("&cursor={}", c));
|
||||
}
|
||||
request("GET", &path, None).await
|
||||
}
|
||||
|
||||
/// GET /api/review?params
|
||||
/// Backend `GET /review` accepts `channelId` and `limit` (not guildId).
|
||||
pub async fn get_review_messages(
|
||||
limit: Option<u32>,
|
||||
channel_id: Option<&str>,
|
||||
) -> Result<PageResult<MessageRecord>, ApiError> {
|
||||
log_debug!("get_review_messages: limit={:?}, channel_id={:?}", limit, channel_id);
|
||||
let mut path = "/api/review".to_string();
|
||||
let mut params = vec![];
|
||||
if let Some(l) = limit {
|
||||
params.push(format!("limit={}", l));
|
||||
}
|
||||
if let Some(c) = channel_id {
|
||||
params.push(format!("channelId={}", c));
|
||||
}
|
||||
if !params.is_empty() {
|
||||
path.push_str(&format!("?{}", params.join("&")));
|
||||
}
|
||||
request("GET", &path, None).await
|
||||
}
|
||||
|
||||
/// GET /api/messages/images?guildId=&limit=
|
||||
pub async fn get_images(
|
||||
guild_id: &str,
|
||||
limit: Option<u32>,
|
||||
) -> Result<PageResult<MessageRecord>, ApiError> {
|
||||
log_debug!("get_images: guild_id={}, limit={:?}", guild_id, limit);
|
||||
let mut path = format!("/api/messages/images?guildId={}", guild_id);
|
||||
if let Some(l) = limit {
|
||||
path.push_str(&format!("&limit={}", l));
|
||||
}
|
||||
request("GET", &path, None).await
|
||||
}
|
||||
|
||||
/// GET /api/messages/detail/{id}
|
||||
pub async fn get_message_detail(id: &str) -> Result<Option<MessageRecord>, ApiError> {
|
||||
log_debug!("get_message_detail: id={}", id);
|
||||
request("GET", &format!("/api/messages/detail/{}", id), None).await
|
||||
}
|
||||
|
||||
/// POST /api/messages/{id}/reanalyze
|
||||
pub async fn reanalyze_message(id: &str) -> Result<(), ApiError> {
|
||||
log_debug!("reanalyze_message: id={}", id);
|
||||
let _: serde_json::Value = request(
|
||||
"POST",
|
||||
&format!("/api/messages/{}/reanalyze", id),
|
||||
Some("{}"),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// POST /api/messages/reanalyze-batch
|
||||
pub async fn reanalyze_batch() -> Result<u64, ApiError> {
|
||||
log_debug!("reanalyze_batch");
|
||||
#[derive(serde::Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct BatchResp {
|
||||
ok: bool,
|
||||
count: u64,
|
||||
}
|
||||
let resp: BatchResp = request("POST", "/api/messages/reanalyze-batch", Some("{}")).await?;
|
||||
Ok(resp.count)
|
||||
}
|
||||
|
||||
/// GET /api/analysis/search?q=&limit=
|
||||
pub async fn search_messages(
|
||||
query: &str,
|
||||
limit: Option<u32>,
|
||||
) -> Result<Vec<MessageRecord>, ApiError> {
|
||||
log_debug!("search_messages: query={}, limit={:?}", query, limit);
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SearchResult {
|
||||
results: Vec<MessageRecord>,
|
||||
}
|
||||
let mut path = format!("/api/analysis/search?q={}", query);
|
||||
if let Some(l) = limit {
|
||||
path.push_str(&format!("&limit={}", l));
|
||||
}
|
||||
let resp: SearchResult = request("GET", &path, None).await?;
|
||||
Ok(resp.results)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
pub mod auth;
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod dashboard;
|
||||
pub mod mascot;
|
||||
pub mod messages;
|
||||
pub mod recordings;
|
||||
pub mod voice;
|
||||
@@ -1,25 +0,0 @@
|
||||
use crate::api::client::{request, request_no_body, ApiError};
|
||||
use shared_types::recording::VoiceRecordingListResponse;
|
||||
/// GET /api/recordings?limit=&cursor=
|
||||
pub async fn get_recordings(
|
||||
limit: Option<u32>,
|
||||
cursor: Option<&str>,
|
||||
) -> Result<VoiceRecordingListResponse, ApiError> {
|
||||
let mut path = "/api/recordings".to_string();
|
||||
let mut params = vec![];
|
||||
if let Some(l) = limit {
|
||||
params.push(format!("limit={}", l));
|
||||
}
|
||||
if let Some(c) = cursor {
|
||||
params.push(format!("cursor={}", c));
|
||||
}
|
||||
if !params.is_empty() {
|
||||
path.push_str(&format!("?{}", params.join("&")));
|
||||
}
|
||||
request("GET", &path, None).await
|
||||
}
|
||||
|
||||
/// DELETE /api/recordings/{id}
|
||||
pub async fn delete_recording(id: &str) -> Result<(), ApiError> {
|
||||
request_no_body("DELETE", &format!("/api/recordings/{}", id)).await
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
use crate::api::client::{request, ApiError};
|
||||
use serde::Serialize;
|
||||
use shared_types::guild::{Channel, Guild};
|
||||
use shared_types::media::MediaState;
|
||||
use shared_types::voice::VoiceStatus;
|
||||
/// GET /api/guilds
|
||||
pub async fn get_guilds() -> Result<Vec<Guild>, ApiError> {
|
||||
request("GET", "/api/guilds", None).await
|
||||
}
|
||||
|
||||
/// GET /api/guilds/{guildId}/voice-channels
|
||||
pub async fn get_voice_channels(guild_id: &str) -> Result<Vec<Channel>, ApiError> {
|
||||
request(
|
||||
"GET",
|
||||
&format!("/api/guilds/{}/voice-channels", guild_id),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// GET /api/guilds/{guildId}/channels
|
||||
pub async fn get_text_channels(guild_id: &str) -> Result<Vec<Channel>, ApiError> {
|
||||
request("GET", &format!("/api/guilds/{}/channels", guild_id), None).await
|
||||
}
|
||||
|
||||
/// GET /api/voice/status
|
||||
pub async fn get_voice_status() -> Result<VoiceStatus, ApiError> {
|
||||
request("GET", "/api/voice/status", None).await
|
||||
}
|
||||
|
||||
/// POST /api/voice/connect { guildId, channelId }
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ConnectPayload {
|
||||
guild_id: String,
|
||||
channel_id: String,
|
||||
}
|
||||
pub async fn connect_voice(guild_id: &str, channel_id: &str) -> Result<VoiceStatus, ApiError> {
|
||||
let body = serde_json::to_string(&ConnectPayload {
|
||||
guild_id: guild_id.to_string(),
|
||||
channel_id: channel_id.to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
request("POST", "/api/voice/connect", Some(&body)).await
|
||||
}
|
||||
|
||||
/// POST /api/voice/disconnect
|
||||
pub async fn disconnect_voice() -> Result<VoiceStatus, ApiError> {
|
||||
request("POST", "/api/voice/disconnect", Some("{}")).await
|
||||
}
|
||||
|
||||
/// GET /api/media/status
|
||||
pub async fn get_media_status() -> Result<MediaState, ApiError> {
|
||||
request("GET", "/api/media/status", None).await
|
||||
}
|
||||
|
||||
/// POST /api/media/queue { source, mode }
|
||||
#[derive(Serialize)]
|
||||
struct MediaQueuePayload {
|
||||
source: String,
|
||||
mode: String,
|
||||
}
|
||||
pub async fn media_queue(source: &str, mode: &str) -> Result<MediaState, ApiError> {
|
||||
let body = serde_json::to_string(&MediaQueuePayload {
|
||||
source: source.to_string(),
|
||||
mode: mode.to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
request("POST", "/api/media/queue", Some(&body)).await
|
||||
}
|
||||
|
||||
/// POST /api/media/skip
|
||||
pub async fn media_skip() -> Result<MediaState, ApiError> {
|
||||
request("POST", "/api/media/skip", Some("{}")).await
|
||||
}
|
||||
|
||||
/// POST /api/media/stop
|
||||
pub async fn media_stop() -> Result<MediaState, ApiError> {
|
||||
request("POST", "/api/media/stop", Some("{}")).await
|
||||
}
|
||||
|
||||
/// POST /api/media/volume { volume }
|
||||
#[derive(Serialize)]
|
||||
struct VolumePayload {
|
||||
volume: f64,
|
||||
}
|
||||
pub async fn media_volume(volume: f64) -> Result<MediaState, ApiError> {
|
||||
let body = serde_json::to_string(&VolumePayload { volume }).unwrap();
|
||||
request("POST", "/api/media/volume", Some(&body)).await
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
use crate::api::config as config_api;
|
||||
use crate::features::dashboard::DashboardPanel;
|
||||
use crate::features::live::LivePanel;
|
||||
use crate::features::messages::MessagesPanel;
|
||||
use crate::features::polish::components::{MascotChatbot, ParticleBackground};
|
||||
use crate::features::polish::{initial_theme, ThemeContext};
|
||||
use crate::layout::sidebar::Sidebar;
|
||||
use crate::ws::context::WsContext;
|
||||
use leptos::prelude::*;
|
||||
use shared_types::ui_state::Tab;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use crate::{log_info, log_warn, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
fn get_ws_url() -> String {
|
||||
web_sys::window()
|
||||
.map(|w| {
|
||||
let loc = w.location();
|
||||
let protocol = loc.protocol().unwrap_or_else(|_| "http:".to_string());
|
||||
let host = loc.host().unwrap_or_else(|_| "localhost:3001".to_string());
|
||||
let ws_proto = if protocol.starts_with("https") {
|
||||
"wss"
|
||||
} else {
|
||||
"ws"
|
||||
};
|
||||
format!("{}://{}/ws", ws_proto, host)
|
||||
})
|
||||
.unwrap_or_else(|| "ws://localhost:3001/ws".to_string())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppConfig {
|
||||
pub monitor_guild_id: RwSignal<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthContext {
|
||||
pub authenticated: RwSignal<bool>,
|
||||
pub password: RwSignal<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UiContext {
|
||||
pub active_tab: RwSignal<Tab>,
|
||||
pub selected_guild: RwSignal<Option<String>>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
let auth = AuthContext {
|
||||
authenticated: RwSignal::new(false),
|
||||
password: RwSignal::new(String::new()),
|
||||
};
|
||||
let ui = UiContext {
|
||||
active_tab: RwSignal::new(Tab::Messages),
|
||||
selected_guild: RwSignal::new(None),
|
||||
};
|
||||
let theme = ThemeContext {
|
||||
theme: RwSignal::new(initial_theme()),
|
||||
};
|
||||
|
||||
provide_context(auth.clone());
|
||||
provide_context(ui.clone());
|
||||
provide_context(theme.clone());
|
||||
|
||||
let config = AppConfig {
|
||||
monitor_guild_id: RwSignal::new(None),
|
||||
};
|
||||
provide_context(config.clone());
|
||||
|
||||
let ws = WsContext::new(&get_ws_url());
|
||||
provide_context(ws.clone());
|
||||
|
||||
ws.connect();
|
||||
log_info!("App mounted, WS connecting to {}", get_ws_url());
|
||||
|
||||
spawn_local({
|
||||
let config = config.clone();
|
||||
async move {
|
||||
match config_api::get_config().await {
|
||||
Ok(cfg) => {
|
||||
log_info!("[config] fetched OK — monitorGuildId={:?}", cfg.monitor_guild_id);
|
||||
config.monitor_guild_id.set(cfg.monitor_guild_id);
|
||||
}
|
||||
Err(e) => {
|
||||
log_warn!("[config] failed to fetch: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Effect::new(move |_| {
|
||||
if auth.authenticated.get() {
|
||||
spawn_local({
|
||||
let config = config.clone();
|
||||
async move {
|
||||
match config_api::get_config().await {
|
||||
Ok(cfg) => {
|
||||
config.monitor_guild_id.set(cfg.monitor_guild_id);
|
||||
}
|
||||
Err(e) => {
|
||||
log_info!("[config] fetch after auth failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<div data-theme=move || theme.theme.get()>
|
||||
<ParticleBackground />
|
||||
|
||||
<div class="app-shell">
|
||||
<Sidebar />
|
||||
|
||||
<div class="app-content">
|
||||
{move || match ui.active_tab.get() {
|
||||
Tab::Messages => view! { <MessagesPanel /> }.into_any(),
|
||||
Tab::Live => view! { <LivePanel /> }.into_any(),
|
||||
Tab::Dashboard => view! { <DashboardPanel /> }.into_any(),
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{move || auth.authenticated.get().then(|| view! { <MascotChatbot /> })}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
use crate::api::auth as auth_api;
|
||||
use crate::app::AuthContext;
|
||||
use crate::app::UiContext;
|
||||
use leptos::prelude::*;
|
||||
use shared_types::ui_state::Tab;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use crate::{log_error, log_info, log_warn, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
#[component]
|
||||
pub fn AuthOverlay() -> impl IntoView {
|
||||
let auth = use_context::<AuthContext>().expect("AuthContext not provided");
|
||||
let ui = use_context::<UiContext>();
|
||||
let (password, set_password) = signal(String::new());
|
||||
let (error, set_error) = signal(Option::<String>::None);
|
||||
let (loading, set_loading) = signal(false);
|
||||
|
||||
let handle_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
let pwd = password.get();
|
||||
if pwd.is_empty() {
|
||||
set_error.set(Some("Password diperlukan".to_string()));
|
||||
return;
|
||||
}
|
||||
set_loading.set(true);
|
||||
set_error.set(None);
|
||||
|
||||
let auth_clone = auth.clone();
|
||||
let pwd_clone = pwd.clone();
|
||||
let set_loading_clone = set_loading;
|
||||
let set_error_clone = set_error;
|
||||
|
||||
spawn_local(async move {
|
||||
match auth_api::login(&pwd_clone).await {
|
||||
Ok(true) => {
|
||||
log_info!("Auth login successful");
|
||||
if let Some(storage) = web_sys::window()
|
||||
.and_then(|w| w.local_storage().ok())
|
||||
.flatten()
|
||||
{
|
||||
let _ = storage.set_item("admin-password", &pwd_clone);
|
||||
}
|
||||
auth_clone.authenticated.set(true);
|
||||
auth_clone.password.set(pwd_clone);
|
||||
}
|
||||
Ok(false) => {
|
||||
log_warn!("Auth login failed - wrong password");
|
||||
set_error_clone.set(Some("Login gagal — password salah".to_string()));
|
||||
}
|
||||
Err(e) => {
|
||||
log_error!("Auth login error: {}", e.message);
|
||||
set_error_clone.set(Some(format!("Error: {}", e.message)));
|
||||
}
|
||||
}
|
||||
set_loading_clone.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
let tab_messages = ui.as_ref().map(|u| u.active_tab);
|
||||
let skip_dismiss = move |_| {
|
||||
if let Some(ref t) = tab_messages {
|
||||
t.set(Tab::Messages);
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="modal-overlay" on:click=skip_dismiss>
|
||||
<div class="modal-content auth-box" on:click=move |ev| ev.stop_propagation()>
|
||||
<div class="modal-body" style="position:relative">
|
||||
<button
|
||||
class="auth-close-btn"
|
||||
on:click=skip_dismiss
|
||||
title="Tutup"
|
||||
>"×"</button>
|
||||
<div class="auth-lock">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" stroke-width="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="auth-title">"Akses Dashboard"</h2>
|
||||
<p class="auth-desc">"Masukkan password admin untuk melanjutkan"</p>
|
||||
<form class="auth-form" on:submit=handle_submit>
|
||||
<input
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="Password"
|
||||
prop:value=password
|
||||
on:input=move |ev| set_password.set(event_target_value(&ev))
|
||||
/>
|
||||
{move || error.get().map(|e| view! {
|
||||
<p class="auth-error">{e}</p>
|
||||
})}
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary btn-lg"
|
||||
disabled=move || loading.get()
|
||||
>
|
||||
{move || if loading.get() { "Memproses..." } else { "Masuk" }}
|
||||
</button>
|
||||
<button
|
||||
on:click=skip_dismiss
|
||||
class="btn btn-ghost btn-sm"
|
||||
>
|
||||
"Lihat dashboard saja"
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::dashboard::DashboardChannel;
|
||||
|
||||
#[component]
|
||||
pub fn ChannelSummaryList(
|
||||
channels: Vec<DashboardChannel>,
|
||||
loading: bool,
|
||||
error: Option<String>,
|
||||
search: String,
|
||||
has_more: bool,
|
||||
on_search_change: Box<dyn Fn(String) + Send + Sync + 'static>,
|
||||
on_load_more: Box<dyn Fn() + Send + Sync + 'static>,
|
||||
on_retry: Box<dyn Fn() + Send + Sync + 'static>,
|
||||
) -> impl IntoView {
|
||||
let search_cb = StoredValue::new(on_search_change);
|
||||
let load_more_cb = StoredValue::new(on_load_more);
|
||||
let retry_cb = StoredValue::new(on_retry);
|
||||
|
||||
view! {
|
||||
<div class="card dashboard-list-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">"Kanal"</div>
|
||||
<p class="card-description">"Ringkasan aktivitas, flagged message, dan budaya kanal."</p>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="dashboard-list-toolbar">
|
||||
<input
|
||||
class="input w-full"
|
||||
placeholder="Search channels..."
|
||||
prop:value=search
|
||||
on:input=move |ev| search_cb.with_value(|cb| cb(event_target_value(&ev)))
|
||||
/>
|
||||
</div>
|
||||
|
||||
{move || {
|
||||
if loading && channels.is_empty() {
|
||||
view! { <ListSkeleton /> }.into_any()
|
||||
} else if let Some(err) = error.clone() {
|
||||
view! {
|
||||
<div class="dashboard-list-empty">
|
||||
<div class="text-error text-xl">"⚠"</div>
|
||||
<p class="text-sm text-secondary">{err}</p>
|
||||
<button class="btn btn-outline btn-sm" on:click=move |_| retry_cb.with_value(|cb| cb())>
|
||||
"Retry"
|
||||
</button>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else if channels.is_empty() {
|
||||
view! {
|
||||
<div class="dashboard-list-empty">
|
||||
<div class="text-2xl">"#"</div>
|
||||
<p class="text-sm text-secondary">"No channels found."</p>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<div class="dashboard-summary-list">
|
||||
{channels.clone().into_iter().map(|channel| view! {
|
||||
<ChannelRow channel=channel />
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
}}
|
||||
|
||||
{move || {
|
||||
(has_more && !loading).then(|| view! {
|
||||
<div class="mt-4 text-center">
|
||||
<button class="btn btn-outline btn-sm" on:click=move |_| load_more_cb.with_value(|cb| cb())>
|
||||
"Load more channels"
|
||||
</button>
|
||||
</div>
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ChannelRow(channel: DashboardChannel) -> impl IntoView {
|
||||
let name = channel
|
||||
.channel_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| channel.channel_id.clone());
|
||||
let summary = channel
|
||||
.culture_summary
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{} messages", format_number(channel.total_messages)));
|
||||
let last_seen = channel.last_message_at.map(format_timestamp);
|
||||
|
||||
view! {
|
||||
<div class="dashboard-summary-row">
|
||||
<div class="dashboard-summary-avatar dashboard-channel-avatar">
|
||||
<span>"#"</span>
|
||||
</div>
|
||||
<div class="dashboard-summary-main">
|
||||
<div class="dashboard-summary-title">{format!("#{}", name)}</div>
|
||||
<div class="dashboard-summary-text">{summary}</div>
|
||||
<div class="dashboard-summary-meta">
|
||||
<span>{format!("{} messages", format_number(channel.total_messages))}</span>
|
||||
<span>{format!("{} flagged", format_number(channel.flagged_count))}</span>
|
||||
{last_seen.map(|t| view! { <span>{format!("Last: {}", t)}</span> })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ListSkeleton() -> impl IntoView {
|
||||
view! {
|
||||
<div class="dashboard-summary-list">
|
||||
{(0..5).map(|_| view! {
|
||||
<div class="dashboard-summary-row">
|
||||
<div class="skeleton skeleton-circular" style="width:40px;height:40px"></div>
|
||||
<div class="dashboard-summary-main">
|
||||
<div class="skeleton" style="height:16px;width:160px"></div>
|
||||
<div class="skeleton mt-2" style="height:14px;width:240px"></div>
|
||||
</div>
|
||||
</div>
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn format_number(value: u64) -> String {
|
||||
let raw = value.to_string();
|
||||
let mut out = String::new();
|
||||
for (idx, ch) in raw.chars().rev().enumerate() {
|
||||
if idx > 0 && idx % 3 == 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out.chars().rev().collect()
|
||||
}
|
||||
|
||||
fn format_timestamp(ts: i64) -> String {
|
||||
let d = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64((ts as f64) * 1000.0));
|
||||
d.to_locale_date_string("en-US", &wasm_bindgen::JsValue::UNDEFINED)
|
||||
.into()
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
pub mod channel_summary_list;
|
||||
pub mod stats_overview;
|
||||
pub mod user_summary_list;
|
||||
|
||||
pub use channel_summary_list::ChannelSummaryList;
|
||||
pub use stats_overview::StatsOverview;
|
||||
pub use user_summary_list::UserSummaryList;
|
||||
@@ -1,150 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::dashboard::{DashboardStats, TopChannel};
|
||||
#[component]
|
||||
pub fn StatsOverview(
|
||||
stats: Option<DashboardStats>,
|
||||
loading: bool,
|
||||
error: Option<String>,
|
||||
on_retry: Box<dyn Fn() + Send + Sync + 'static>,
|
||||
) -> impl IntoView {
|
||||
let retry = StoredValue::new(on_retry);
|
||||
|
||||
view! {
|
||||
<div class="dashboard-stats">
|
||||
{move || {
|
||||
if loading {
|
||||
view! { <StatsSkeleton /> }.into_any()
|
||||
} else if let Some(err) = error.clone() {
|
||||
view! {
|
||||
<div class="card p-6 text-center">
|
||||
<div class="text-error text-2xl mb-2">"⚠"</div>
|
||||
<p class="text-sm text-secondary mb-4">{err}</p>
|
||||
<button class="btn btn-outline btn-sm" on:click=move |_| retry.with_value(|cb| cb())>
|
||||
"Retry"
|
||||
</button>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else if let Some(stats) = stats.clone() {
|
||||
view! {
|
||||
<div class="dashboard-stats-grid">
|
||||
<MetricCard label="Total Messages" value=stats.total_messages icon="💬" tone="primary" />
|
||||
<MetricCard label="Today's Messages" value=stats.today_messages icon="📅" tone="success" />
|
||||
<MetricCard label="Total Users" value=stats.total_users icon="👥" tone="primary" />
|
||||
<MetricCard label="Active 24h" value=stats.active_users_24h icon="🟢" tone="success" />
|
||||
<MetricCard label="Flagged" value=stats.total_flagged icon="🚩" tone="error" />
|
||||
<MetricCard label="Clean" value=stats.total_clean icon="✅" tone="success" />
|
||||
<MetricCard label="Voice Recordings" value=stats.total_voice_recordings icon="🎙" tone="info" />
|
||||
<MetricCard label="AI Profiles" value=stats.total_profiles icon="🧠" tone="warning" />
|
||||
|
||||
<div class="card dashboard-wide-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">"Top Channels"</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<TopChannels channels=stats.top_channels.clone() />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card dashboard-wide-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">"Moderation Queue"</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="dashboard-moderation-grid">
|
||||
<QueueMetric label="Pending" value=stats.moderation_overview.pending tone="secondary" />
|
||||
<QueueMetric label="Processing" value=stats.moderation_overview.processing tone="warning" />
|
||||
<QueueMetric label="Errors" value=stats.moderation_overview.error tone="error" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<div class="card p-6 text-center">
|
||||
<p class="text-sm text-secondary">"No dashboard data available yet."</p>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn MetricCard(
|
||||
label: &'static str,
|
||||
value: u64,
|
||||
icon: &'static str,
|
||||
tone: &'static str,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="card dashboard-metric-card">
|
||||
<div class="dashboard-metric-content">
|
||||
<div>
|
||||
<div class="dashboard-metric-label">{label}</div>
|
||||
<div class="dashboard-metric-value">{format_number(value)}</div>
|
||||
</div>
|
||||
<div class=format!("dashboard-metric-icon tone-{}", tone)>{icon}</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn QueueMetric(label: &'static str, value: u64, tone: &'static str) -> impl IntoView {
|
||||
view! {
|
||||
<div class=format!("dashboard-queue-card tone-{}", tone)>
|
||||
<div class="dashboard-queue-value">{format_number(value)}</div>
|
||||
<div class="dashboard-queue-label">{label}</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn TopChannels(channels: Vec<TopChannel>) -> impl IntoView {
|
||||
if channels.is_empty() {
|
||||
return view! { <p class="text-sm text-secondary">"No channel data yet."</p> }.into_any();
|
||||
}
|
||||
|
||||
view! {
|
||||
<div class="dashboard-top-channels">
|
||||
{channels.into_iter().map(|ch| {
|
||||
let name = ch.channel_name.unwrap_or_else(|| ch.channel_id.clone());
|
||||
view! {
|
||||
<div class="dashboard-top-channel-row">
|
||||
<span class="truncate">{format!("#{}", name)}</span>
|
||||
<span class="font-semibold">{format_number(ch.message_count)}</span>
|
||||
</div>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn StatsSkeleton() -> impl IntoView {
|
||||
view! {
|
||||
<div class="dashboard-stats-grid">
|
||||
{(0..8).map(|_| view! {
|
||||
<div class="card dashboard-metric-card">
|
||||
<div class="skeleton" style="height:14px;width:96px"></div>
|
||||
<div class="skeleton mt-2" style="height:32px;width:72px"></div>
|
||||
</div>
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn format_number(value: u64) -> String {
|
||||
let raw = value.to_string();
|
||||
let mut out = String::new();
|
||||
for (idx, ch) in raw.chars().rev().enumerate() {
|
||||
if idx > 0 && idx % 3 == 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out.chars().rev().collect()
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::dashboard::DashboardUser;
|
||||
|
||||
#[component]
|
||||
pub fn UserSummaryList(
|
||||
users: Vec<DashboardUser>,
|
||||
loading: bool,
|
||||
error: Option<String>,
|
||||
search: String,
|
||||
has_more: bool,
|
||||
on_search_change: Box<dyn Fn(String) + Send + Sync + 'static>,
|
||||
on_load_more: Box<dyn Fn() + Send + Sync + 'static>,
|
||||
on_retry: Box<dyn Fn() + Send + Sync + 'static>,
|
||||
) -> impl IntoView {
|
||||
let search_cb = StoredValue::new(on_search_change);
|
||||
let load_more_cb = StoredValue::new(on_load_more);
|
||||
let retry_cb = StoredValue::new(on_retry);
|
||||
|
||||
view! {
|
||||
<div class="card dashboard-list-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">"Pengguna"</div>
|
||||
<p class="card-description">"Ringkasan aktivitas dan trust score pengguna."</p>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="dashboard-list-toolbar">
|
||||
<input
|
||||
class="input w-full"
|
||||
placeholder="Search users..."
|
||||
prop:value=search
|
||||
on:input=move |ev| search_cb.with_value(|cb| cb(event_target_value(&ev)))
|
||||
/>
|
||||
</div>
|
||||
|
||||
{move || {
|
||||
if loading && users.is_empty() {
|
||||
view! { <ListSkeleton /> }.into_any()
|
||||
} else if let Some(err) = error.clone() {
|
||||
view! {
|
||||
<div class="dashboard-list-empty">
|
||||
<div class="text-error text-xl">"⚠"</div>
|
||||
<p class="text-sm text-secondary">{err}</p>
|
||||
<button class="btn btn-outline btn-sm" on:click=move |_| retry_cb.with_value(|cb| cb())>
|
||||
"Retry"
|
||||
</button>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else if users.is_empty() {
|
||||
view! {
|
||||
<div class="dashboard-list-empty">
|
||||
<div class="text-2xl">"👤"</div>
|
||||
<p class="text-sm text-secondary">"No users found."</p>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<div class="dashboard-summary-list">
|
||||
{users.clone().into_iter().map(|user| view! {
|
||||
<UserRow user=user />
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
}}
|
||||
|
||||
{move || {
|
||||
(has_more && !loading).then(|| view! {
|
||||
<div class="mt-4 text-center">
|
||||
<button class="btn btn-outline btn-sm" on:click=move |_| load_more_cb.with_value(|cb| cb())>
|
||||
"Load more users"
|
||||
</button>
|
||||
</div>
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn UserRow(user: DashboardUser) -> impl IntoView {
|
||||
let name = user
|
||||
.username
|
||||
.clone()
|
||||
.unwrap_or_else(|| user.user_id.clone());
|
||||
let summary = user
|
||||
.profile_summary
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{} messages", format_number(user.total_messages)));
|
||||
let trust = user.trust_score.map(|score| format!("Trust: {:.2}", score));
|
||||
let last_seen = user.last_message_at.map(format_timestamp);
|
||||
|
||||
view! {
|
||||
<div class="dashboard-summary-row">
|
||||
<div class="dashboard-summary-avatar">
|
||||
{if let Some(url) = user.avatar_url.clone() {
|
||||
view! { <img src=url alt="" class="dashboard-summary-avatar-img" /> }.into_any()
|
||||
} else {
|
||||
view! { <span>"👤"</span> }.into_any()
|
||||
}}
|
||||
</div>
|
||||
<div class="dashboard-summary-main">
|
||||
<div class="dashboard-summary-title">{name}</div>
|
||||
<div class="dashboard-summary-text">{summary}</div>
|
||||
<div class="dashboard-summary-meta">
|
||||
<span>{format!("{} flagged", format_number(user.flagged_count))}</span>
|
||||
{trust.map(|t| view! { <span>{t}</span> })}
|
||||
{last_seen.map(|t| view! { <span>{format!("Last: {}", t)}</span> })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ListSkeleton() -> impl IntoView {
|
||||
view! {
|
||||
<div class="dashboard-summary-list">
|
||||
{(0..5).map(|_| view! {
|
||||
<div class="dashboard-summary-row">
|
||||
<div class="skeleton skeleton-circular" style="width:40px;height:40px"></div>
|
||||
<div class="dashboard-summary-main">
|
||||
<div class="skeleton" style="height:16px;width:160px"></div>
|
||||
<div class="skeleton mt-2" style="height:14px;width:240px"></div>
|
||||
</div>
|
||||
</div>
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn format_number(value: u64) -> String {
|
||||
let raw = value.to_string();
|
||||
let mut out = String::new();
|
||||
for (idx, ch) in raw.chars().rev().enumerate() {
|
||||
if idx > 0 && idx % 3 == 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out.chars().rev().collect()
|
||||
}
|
||||
|
||||
fn format_timestamp(ts: i64) -> String {
|
||||
let d = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64((ts as f64) * 1000.0));
|
||||
d.to_locale_date_string("en-US", &wasm_bindgen::JsValue::UNDEFINED)
|
||||
.into()
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
pub mod components;
|
||||
|
||||
use components::{ChannelSummaryList, StatsOverview, UserSummaryList};
|
||||
use leptos::prelude::*;
|
||||
use shared_types::dashboard::{DashboardChannel, DashboardStats, DashboardUser};
|
||||
use std::sync::Arc;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use crate::{log_error, log_info, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum DashboardTab {
|
||||
Stats,
|
||||
Users,
|
||||
Channels,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn DashboardPanel() -> impl IntoView {
|
||||
let active_tab = RwSignal::new(DashboardTab::Stats);
|
||||
|
||||
let stats = RwSignal::new(None::<DashboardStats>);
|
||||
let stats_loading = RwSignal::new(false);
|
||||
let stats_error = RwSignal::new(None::<String>);
|
||||
|
||||
let users = RwSignal::new(Vec::<DashboardUser>::new());
|
||||
let users_loading = RwSignal::new(false);
|
||||
let users_error = RwSignal::new(None::<String>);
|
||||
let users_search = RwSignal::new(String::new());
|
||||
let users_cursor = RwSignal::new(None::<String>);
|
||||
|
||||
let channels = RwSignal::new(Vec::<DashboardChannel>::new());
|
||||
let channels_loading = RwSignal::new(false);
|
||||
let channels_error = RwSignal::new(None::<String>);
|
||||
let channels_search = RwSignal::new(String::new());
|
||||
let channels_cursor = RwSignal::new(None::<String>);
|
||||
|
||||
let fetch_stats: Arc<dyn Fn() + Send + Sync + 'static> = Arc::new(move || {
|
||||
stats_loading.set(true);
|
||||
stats_error.set(None);
|
||||
log_info!("Dashboard fetching stats...");
|
||||
spawn_local(async move {
|
||||
match crate::api::dashboard::get_dashboard_stats().await {
|
||||
Ok(data) => {
|
||||
log_info!("Dashboard stats loaded: {} messages", data.total_messages);
|
||||
stats.set(Some(data));
|
||||
}
|
||||
Err(err) => {
|
||||
log_error!("Dashboard stats error: {}", err);
|
||||
stats_error.set(Some(format!("Failed to load stats: {}", err)));
|
||||
}
|
||||
}
|
||||
stats_loading.set(false);
|
||||
});
|
||||
});
|
||||
|
||||
let fetch_users: Arc<dyn Fn(bool) + Send + Sync + 'static> = Arc::new(move |reset: bool| {
|
||||
if users_loading.get() {
|
||||
return;
|
||||
}
|
||||
users_loading.set(true);
|
||||
users_error.set(None);
|
||||
log_info!("Dashboard fetching users...");
|
||||
|
||||
let cursor = if reset { None } else { users_cursor.get() };
|
||||
let search = users_search.get();
|
||||
spawn_local(async move {
|
||||
let search_ref = (!search.trim().is_empty()).then_some(search.trim());
|
||||
match crate::api::dashboard::get_dashboard_users(
|
||||
Some(20),
|
||||
cursor.as_deref(),
|
||||
search_ref,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(page) => {
|
||||
log_info!("Dashboard users loaded: {} users", page.data.len());
|
||||
if reset {
|
||||
users.set(page.data);
|
||||
} else {
|
||||
let mut current = users.get();
|
||||
current.extend(page.data);
|
||||
users.set(current);
|
||||
}
|
||||
users_cursor.set(page.next_cursor);
|
||||
}
|
||||
Err(err) => {
|
||||
log_error!("Dashboard users error: {}", err);
|
||||
users_error.set(Some(format!("Failed to load users: {}", err)));
|
||||
}
|
||||
}
|
||||
users_loading.set(false);
|
||||
});
|
||||
});
|
||||
|
||||
let fetch_channels: Arc<dyn Fn(bool) + Send + Sync + 'static> = Arc::new(move |reset: bool| {
|
||||
if channels_loading.get() {
|
||||
return;
|
||||
}
|
||||
channels_loading.set(true);
|
||||
channels_error.set(None);
|
||||
log_info!("Dashboard fetching channels...");
|
||||
|
||||
let cursor = if reset { None } else { channels_cursor.get() };
|
||||
let search = channels_search.get();
|
||||
spawn_local(async move {
|
||||
let search_ref = (!search.trim().is_empty()).then_some(search.trim());
|
||||
match crate::api::dashboard::get_dashboard_channels(
|
||||
Some(20),
|
||||
cursor.as_deref(),
|
||||
search_ref,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(page) => {
|
||||
log_info!("Dashboard channels loaded: {} channels", page.data.len());
|
||||
if reset {
|
||||
channels.set(page.data);
|
||||
} else {
|
||||
let mut current = channels.get();
|
||||
current.extend(page.data);
|
||||
channels.set(current);
|
||||
}
|
||||
channels_cursor.set(page.next_cursor);
|
||||
}
|
||||
Err(err) => {
|
||||
log_error!("Dashboard channels error: {}", err);
|
||||
channels_error.set(Some(format!("Failed to load channels: {}", err)));
|
||||
}
|
||||
}
|
||||
channels_loading.set(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Initial fetch on mount (use spawn_local to avoid reactive dependency tracking)
|
||||
{
|
||||
let fetch_stats = fetch_stats.clone();
|
||||
let fetch_users = fetch_users.clone();
|
||||
let fetch_channels = fetch_channels.clone();
|
||||
spawn_local(async move {
|
||||
fetch_stats();
|
||||
fetch_users(true);
|
||||
fetch_channels(true);
|
||||
});
|
||||
}
|
||||
|
||||
view! {
|
||||
<div class="dashboard-panel">
|
||||
<div class="dashboard-header">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold">"Dashboard Guild"</h2>
|
||||
<p class="text-sm text-secondary mt-2">
|
||||
"Pantau statistik, profil pengguna, dan aktivitas kanal komunitas IMPHNEN."
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab-list mb-6">
|
||||
<DashboardTabButton tab=DashboardTab::Stats active_tab=active_tab label="Statistik" icon="📊" />
|
||||
<DashboardTabButton tab=DashboardTab::Users active_tab=active_tab label="Pengguna" icon="👥" />
|
||||
<DashboardTabButton tab=DashboardTab::Channels active_tab=active_tab label="Kanal" icon="#" />
|
||||
</div>
|
||||
|
||||
<div class="tab-content" style:display=move || if active_tab.get() == DashboardTab::Stats { "block" } else { "none" }>
|
||||
{move || {
|
||||
let on_retry = {
|
||||
let fetch_stats = fetch_stats.clone();
|
||||
Box::new(move || fetch_stats())
|
||||
};
|
||||
view! {
|
||||
<StatsOverview
|
||||
stats=stats.get()
|
||||
loading=stats_loading.get()
|
||||
error=stats_error.get()
|
||||
on_retry=on_retry
|
||||
/>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="tab-content" style:display=move || if active_tab.get() == DashboardTab::Users { "block" } else { "none" }>
|
||||
{move || {
|
||||
let on_search_change = {
|
||||
let fetch_users = fetch_users.clone();
|
||||
Box::new(move |value| {
|
||||
users_search.set(value);
|
||||
users_cursor.set(None);
|
||||
fetch_users(true);
|
||||
})
|
||||
};
|
||||
let on_load_more = {
|
||||
let fetch_users = fetch_users.clone();
|
||||
Box::new(move || fetch_users(false))
|
||||
};
|
||||
let on_retry = {
|
||||
let fetch_users = fetch_users.clone();
|
||||
Box::new(move || fetch_users(true))
|
||||
};
|
||||
view! {
|
||||
<UserSummaryList
|
||||
users=users.get()
|
||||
loading=users_loading.get()
|
||||
error=users_error.get()
|
||||
search=users_search.get()
|
||||
has_more=users_cursor.get().is_some()
|
||||
on_search_change=on_search_change
|
||||
on_load_more=on_load_more
|
||||
on_retry=on_retry
|
||||
/>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="tab-content" style:display=move || if active_tab.get() == DashboardTab::Channels { "block" } else { "none" }>
|
||||
{move || {
|
||||
let on_search_change = {
|
||||
let fetch_channels = fetch_channels.clone();
|
||||
Box::new(move |value| {
|
||||
channels_search.set(value);
|
||||
channels_cursor.set(None);
|
||||
fetch_channels(true);
|
||||
})
|
||||
};
|
||||
let on_load_more = {
|
||||
let fetch_channels = fetch_channels.clone();
|
||||
Box::new(move || fetch_channels(false))
|
||||
};
|
||||
let on_retry = {
|
||||
let fetch_channels = fetch_channels.clone();
|
||||
Box::new(move || fetch_channels(true))
|
||||
};
|
||||
view! {
|
||||
<ChannelSummaryList
|
||||
channels=channels.get()
|
||||
loading=channels_loading.get()
|
||||
error=channels_error.get()
|
||||
search=channels_search.get()
|
||||
has_more=channels_cursor.get().is_some()
|
||||
on_search_change=on_search_change
|
||||
on_load_more=on_load_more
|
||||
on_retry=on_retry
|
||||
/>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn DashboardTabButton(
|
||||
tab: DashboardTab,
|
||||
active_tab: RwSignal<DashboardTab>,
|
||||
label: &'static str,
|
||||
icon: &'static str,
|
||||
) -> impl IntoView {
|
||||
let tab_for_class = tab.clone();
|
||||
let tab_for_aria = tab.clone();
|
||||
let tab_for_click = tab;
|
||||
|
||||
view! {
|
||||
<button
|
||||
class="tab-trigger"
|
||||
class:active=move || active_tab.get() == tab_for_class
|
||||
aria-selected=move || if active_tab.get() == tab_for_aria { "true" } else { "false" }
|
||||
on:click=move |_| active_tab.set(tab_for_click.clone())
|
||||
>
|
||||
<span>{icon}</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
pub mod pcm_decoder;
|
||||
pub mod ring_buffer;
|
||||
@@ -1,70 +0,0 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// PCM Frame decoded from binary WebSocket data
|
||||
/// Format: [u32 userId (4 bytes)][i16 samples (N bytes)]
|
||||
pub struct PcmFrame {
|
||||
pub user_id: u32,
|
||||
pub samples: Vec<f32>, // Normalized to [-1.0, 1.0]
|
||||
}
|
||||
|
||||
/// Decode a binary WebSocket message into PCM frames
|
||||
/// Returns None if data is too short or malformed
|
||||
pub fn decode_pcm_frame(data: &[u8]) -> Option<PcmFrame> {
|
||||
if data.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let user_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
|
||||
let sample_bytes = &data[4..];
|
||||
let sample_count = sample_bytes.len() / 2;
|
||||
|
||||
if sample_count == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let samples = decode_i16_samples(sample_bytes);
|
||||
Some(PcmFrame { user_id, samples })
|
||||
}
|
||||
|
||||
/// Decode raw i16 PCM bytes to normalized f32 samples [-1.0, 1.0]
|
||||
pub fn decode_i16_samples(data: &[u8]) -> Vec<f32> {
|
||||
let count = data.len() / 2;
|
||||
let mut out = Vec::with_capacity(count);
|
||||
|
||||
for i in 0..count {
|
||||
let offset = i * 2;
|
||||
if offset + 1 < data.len() {
|
||||
let sample = i16::from_le_bytes([data[offset], data[offset + 1]]);
|
||||
out.push((sample as f32) / 32768.0);
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Encode f32 samples [-1.0, 1.0] to base64 for WebSocket transmission
|
||||
/// Uses JavaScript btoa for encoding
|
||||
pub fn encode_samples_to_base64(samples: &[f32]) -> String {
|
||||
// Convert f32 samples to i16 bytes
|
||||
let mut bytes = Vec::with_capacity(samples.len() * 2);
|
||||
for &sample in samples {
|
||||
let clamped = sample.clamp(-1.0, 1.0);
|
||||
let int_sample = (clamped * 32767.0) as i16;
|
||||
bytes.extend_from_slice(&int_sample.to_le_bytes());
|
||||
}
|
||||
encode_bytes_base64(&bytes)
|
||||
}
|
||||
|
||||
/// Encode raw bytes to base64 using JavaScript's btoa via wasm-bindgen
|
||||
fn encode_bytes_base64(data: &[u8]) -> String {
|
||||
// Convert bytes 0-255 to a Latin-1 string (each byte → char with same codepoint)
|
||||
let latin1: String = data.iter().map(|&b| b as char).collect();
|
||||
js_btoa(&latin1)
|
||||
}
|
||||
|
||||
/// Direct wasm-bindgen binding to the browser's btoa function
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_name = btoa)]
|
||||
fn js_btoa(input: &str) -> String;
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// AudioRingBuffer — Fixed-size circular buffer for real-time PCM streaming
|
||||
/// Provides thread-safe write/read with automatic overwrite protection
|
||||
pub struct AudioRingBuffer {
|
||||
buffer: Vec<f32>,
|
||||
capacity: usize,
|
||||
write_pos: usize,
|
||||
read_pos: usize,
|
||||
available: usize,
|
||||
}
|
||||
|
||||
impl AudioRingBuffer {
|
||||
/// Create a new ring buffer with given capacity (in samples)
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
buffer: vec![0.0; capacity],
|
||||
capacity,
|
||||
write_pos: 0,
|
||||
read_pos: 0,
|
||||
available: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write samples to the ring buffer. Overwrites oldest data if full.
|
||||
pub fn write(&mut self, samples: &[f32]) {
|
||||
let mut written = 0;
|
||||
while written < samples.len() {
|
||||
let chunk = (samples.len() - written).min(self.capacity - self.write_pos);
|
||||
let src = &samples[written..written + chunk];
|
||||
let dest = &mut self.buffer[self.write_pos..self.write_pos + chunk];
|
||||
dest.copy_from_slice(src);
|
||||
written += chunk;
|
||||
self.write_pos = (self.write_pos + chunk) % self.capacity;
|
||||
self.available = (self.available + chunk).min(self.capacity);
|
||||
// If we overwrote unread data, advance read_pos
|
||||
if self.available == self.capacity {
|
||||
self.read_pos = self.write_pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read up to `max_samples` from the buffer. Returns the samples read.
|
||||
pub fn read(&mut self, max_samples: usize) -> Vec<f32> {
|
||||
let to_read = max_samples.min(self.available);
|
||||
let mut out = Vec::with_capacity(to_read);
|
||||
let mut remaining = to_read;
|
||||
|
||||
while remaining > 0 {
|
||||
let chunk = remaining.min(self.capacity - self.read_pos);
|
||||
out.extend_from_slice(&self.buffer[self.read_pos..self.read_pos + chunk]);
|
||||
remaining -= chunk;
|
||||
self.read_pos = (self.read_pos + chunk) % self.capacity;
|
||||
}
|
||||
|
||||
self.available -= to_read;
|
||||
out
|
||||
}
|
||||
|
||||
/// Number of samples available to read
|
||||
pub fn available_samples(&self) -> usize {
|
||||
self.available
|
||||
}
|
||||
|
||||
/// Clear all buffered data
|
||||
pub fn clear(&mut self) {
|
||||
self.write_pos = 0;
|
||||
self.read_pos = 0;
|
||||
self.available = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe wrapper around AudioRingBuffer
|
||||
pub struct SharedRingBuffer {
|
||||
inner: Arc<Mutex<AudioRingBuffer>>,
|
||||
}
|
||||
|
||||
impl SharedRingBuffer {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(AudioRingBuffer::new(capacity))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write(&self, samples: &[f32]) {
|
||||
if let Ok(mut guard) = self.inner.lock() {
|
||||
guard.write(samples);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read(&self, max_samples: usize) -> Vec<f32> {
|
||||
if let Ok(mut guard) = self.inner.lock() {
|
||||
guard.read(max_samples)
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn available_samples(&self) -> usize {
|
||||
if let Ok(guard) = self.inner.lock() {
|
||||
guard.available_samples()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
if let Ok(mut guard) = self.inner.lock() {
|
||||
guard.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clone_inner(&self) -> Arc<Mutex<AudioRingBuffer>> {
|
||||
self.inner.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for SharedRingBuffer {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
inner: self.inner.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::voice::ActiveSpeaker;
|
||||
|
||||
/// ActiveSpeakers component for Leptos
|
||||
/// Displays a real-time list of speaking users with avatar and status indicator
|
||||
#[component]
|
||||
pub fn ActiveSpeakers(
|
||||
#[prop(optional)] speakers: RwSignal<Vec<ActiveSpeaker>>,
|
||||
#[prop(optional)] class: &'static str,
|
||||
) -> impl IntoView {
|
||||
let empty_state = move || speakers.get().is_empty();
|
||||
|
||||
view! {
|
||||
<div class=class>
|
||||
<Show
|
||||
when=empty_state
|
||||
fallback=move || {
|
||||
view! {
|
||||
<div class="speak-list">
|
||||
<For
|
||||
each=move || speakers.get()
|
||||
key=|s| s.user_id.clone() + &s.username
|
||||
let:speaker
|
||||
>
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;border-radius:0.75rem;border:1px solid var(--surface-border);background:var(--surface-base);padding:0.75rem">
|
||||
<div style="width:2rem;height:2rem;flex-shrink:0">
|
||||
{speaker.avatar.as_ref().map(|avatar_url| {
|
||||
let url = avatar_url.clone();
|
||||
view! {
|
||||
<img
|
||||
src=url
|
||||
alt=""
|
||||
style="width:2rem;height:2rem;border-radius:9999px;object-fit:cover;box-shadow:0 0 0 2px rgba(35,161,235,0.3)"
|
||||
/>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
<div style="min-width:0;flex:1">
|
||||
<div class="truncate text-sm font-medium">
|
||||
{speaker.username.clone()}
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.375rem">
|
||||
<span style=move || {
|
||||
if speaker.speaking {
|
||||
"display:inline-block;width:0.5rem;height:0.5rem;border-radius:9999px;background:#10b981"
|
||||
} else {
|
||||
"display:inline-block;width:0.5rem;height:0.5rem;border-radius:9999px;background:color-mix(in srgb, var(--text-tertiary) 40%, transparent)"
|
||||
}
|
||||
}></span>
|
||||
<span style=move || {
|
||||
if speaker.speaking {
|
||||
"font-size:0.75rem;font-weight:500;color:#059669"
|
||||
} else {
|
||||
"font-size:0.75rem;font-weight:500;color:var(--text-secondary)"
|
||||
}
|
||||
}>
|
||||
{move || if speaker.speaking { "Speaking" } else { "Silent" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</For>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
>
|
||||
<div style="border-radius:0.75rem;border:1px solid var(--surface-border);background:var(--surface-base);padding:2rem;text-align:center">
|
||||
<div>
|
||||
<div style="font-size:2.25rem;line-height:2.5rem">
|
||||
"🎤"
|
||||
</div>
|
||||
<p style="font-size:0.875rem;color:var(--text-secondary)">
|
||||
"No active speakers"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// AudioVisualizer — Real-time 32-bar frequency spectrum display
|
||||
/// Simplified implementation using CSS bars updated via signals
|
||||
#[component]
|
||||
pub fn AudioVisualizer(
|
||||
#[prop(default = true)] _active: bool,
|
||||
#[prop(optional)] pcm_data: Option<Arc<Mutex<Vec<f32>>>>,
|
||||
) -> impl IntoView {
|
||||
let bars = RwSignal::new(vec![0.0; 32]);
|
||||
let (tick, set_tick) = signal(0u32);
|
||||
|
||||
// Drive periodic updates: increment tick every 100ms
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
loop {
|
||||
gloo_timers::future::TimeoutFuture::new(100).await;
|
||||
set_tick.update(|t| *t = t.wrapping_add(1));
|
||||
}
|
||||
});
|
||||
|
||||
// Effect reacts to tick changes, updating bars from PCM data each frame
|
||||
Effect::new(move |_| {
|
||||
tick.get(); // Track — Effect re-runs on each tick (every 100ms)
|
||||
if let Some(ref pcm_arc) = pcm_data {
|
||||
if let Ok(pcm_vec) = pcm_arc.lock() {
|
||||
let computed = compute_frequency_bands(&pcm_vec);
|
||||
bars.update(|b| {
|
||||
for (i, band) in b.iter_mut().enumerate() {
|
||||
let target = computed.get(i).copied().unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
*band = *band * 0.7 + target * 0.3; // Smooth decay
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="audio-visualizer">
|
||||
<div class="audio-visualizer-bars">
|
||||
{(0..32).map(|i| {
|
||||
view! {
|
||||
<div
|
||||
class="audio-bar"
|
||||
style=move || {
|
||||
let height = bars.get()[i] * 100.0;
|
||||
format!("height: {}%", height)
|
||||
}
|
||||
></div>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute 32-band frequency spectrum from PCM samples
|
||||
fn compute_frequency_bands(pcm_samples: &[f32]) -> Vec<f32> {
|
||||
let mut bands = vec![0.0; 32];
|
||||
|
||||
if pcm_samples.is_empty() {
|
||||
return bands;
|
||||
}
|
||||
|
||||
let samples_per_band = (pcm_samples.len() / 32).max(1);
|
||||
|
||||
for (band_idx, band) in bands.iter_mut().enumerate() {
|
||||
let start = band_idx * samples_per_band;
|
||||
let end = ((band_idx + 1) * samples_per_band).min(pcm_samples.len());
|
||||
|
||||
if start < pcm_samples.len() {
|
||||
let slice = &pcm_samples[start..end];
|
||||
let rms = (slice.iter().map(|s| s * s).sum::<f32>() / slice.len() as f32).sqrt();
|
||||
*band = rms.min(1.0);
|
||||
}
|
||||
}
|
||||
|
||||
bands
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// MicLevelMeter — Horizontal level indicator for microphone input
|
||||
/// Displays 0-100% amplitude as a filling bar with smooth decay
|
||||
#[component]
|
||||
pub fn MicLevelMeter(
|
||||
#[prop(default = true)] active: bool,
|
||||
#[prop(optional)] pcm_data: Option<Arc<Mutex<Vec<f32>>>>,
|
||||
#[prop(optional)] label: Option<&'static str>,
|
||||
) -> impl IntoView {
|
||||
let level = RwSignal::new(0.0f32);
|
||||
let peak = RwSignal::new(0.0f32);
|
||||
let (tick, set_tick) = signal(0u32);
|
||||
|
||||
// Drive periodic updates: increment tick every 100ms
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
loop {
|
||||
gloo_timers::future::TimeoutFuture::new(100).await;
|
||||
set_tick.update(|t| *t = t.wrapping_add(1));
|
||||
}
|
||||
});
|
||||
|
||||
// Effect reacts to tick changes, updating level from PCM data each frame
|
||||
Effect::new(move |_| {
|
||||
tick.get(); // Track — Effect re-runs on each tick
|
||||
if !active {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(ref pcm_arc) = pcm_data {
|
||||
if let Ok(pcm_vec) = pcm_arc.lock() {
|
||||
let current_level = compute_rms(&pcm_vec);
|
||||
level.update(|l| {
|
||||
*l = *l * 0.8 + current_level * 0.2; // Smooth decay
|
||||
});
|
||||
peak.update(|p| {
|
||||
*p = (*p * 0.95).max(current_level); // Peak hold with decay
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let level_percent = move || (level.get() * 100.0).min(100.0);
|
||||
let peak_percent = move || (peak.get() * 100.0).min(100.0);
|
||||
|
||||
// Determine color based on level
|
||||
let level_color = move || {
|
||||
let l = level.get();
|
||||
if l < 0.5 {
|
||||
"bg-green-500"
|
||||
} else if l < 0.75 {
|
||||
"bg-yellow-500"
|
||||
} else {
|
||||
"bg-red-500"
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="mic-level-meter">
|
||||
{label.map(|l| view! {
|
||||
<label style="font-size:0.75rem;font-weight:500;color:var(--text-secondary);display:block;margin-bottom:var(--space-1)">{l}</label>
|
||||
})}
|
||||
<div class="mic-row">
|
||||
{/* Main level bar */}
|
||||
<div class="mic-track">
|
||||
<div
|
||||
class=move || format!("h-full {} transition-all", level_color())
|
||||
style=move || format!("width: {}%", level_percent())
|
||||
></div>
|
||||
{/* Peak indicator */}
|
||||
<div
|
||||
class="mic-clip"
|
||||
style=move || format!("left: {}%", peak_percent())
|
||||
></div>
|
||||
</div>
|
||||
{/* Percentage display */}
|
||||
<span style="font-size:0.75rem;font-family:monospace;width:2rem;text-align:right">
|
||||
{move || format!("{}%", (level_percent() as u8))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute RMS (Root Mean Square) amplitude from PCM samples
|
||||
/// Returns normalized value 0.0-1.0
|
||||
fn compute_rms(samples: &[f32]) -> f32 {
|
||||
if samples.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mean_square = samples.iter().map(|s| s * s).sum::<f32>() / samples.len() as f32;
|
||||
mean_square.sqrt()
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
pub mod active_speakers;
|
||||
pub mod audio_visualizer;
|
||||
pub mod mic_level_meter;
|
||||
pub mod music_sub_panel;
|
||||
pub mod now_playing;
|
||||
pub mod recordings_sub_panel;
|
||||
pub mod screen_sub_panel;
|
||||
pub mod voice_connection_card;
|
||||
pub mod waveform_player;
|
||||
|
||||
pub use active_speakers::ActiveSpeakers;
|
||||
pub use audio_visualizer::AudioVisualizer;
|
||||
pub use mic_level_meter::MicLevelMeter;
|
||||
pub use music_sub_panel::MusicSubPanel;
|
||||
pub use now_playing::NowPlaying;
|
||||
pub use recordings_sub_panel::RecordingsSubPanel;
|
||||
pub use screen_sub_panel::ScreenSubPanel;
|
||||
pub use voice_connection_card::VoiceConnectionCard;
|
||||
pub use waveform_player::WaveformPlayer;
|
||||
@@ -1,53 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// MusicSubPanel — Music playlist controls and URL input
|
||||
#[component]
|
||||
pub fn MusicSubPanel(
|
||||
#[prop(optional)] on_queue: Option<Box<dyn Fn(String) + Send + Sync + 'static>>,
|
||||
) -> impl IntoView {
|
||||
let (url_input, set_url_input) = signal::<String>(String::new());
|
||||
|
||||
let handle_queue_click = move |_| {
|
||||
let url = url_input.get_untracked().trim().to_string();
|
||||
if !url.is_empty() {
|
||||
if let Some(ref cb) = on_queue {
|
||||
cb(url.clone());
|
||||
set_url_input.set(String::new());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title" style="display:flex;align-items:center;gap:var(--space-2)">
|
||||
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M9 8h6v8h-6z"></path>
|
||||
</svg>
|
||||
"Music"
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content music-body">
|
||||
<div class="music-body">
|
||||
<label style="font-size:0.75rem;font-weight:500;color:var(--text-secondary)">"YouTube URL or Search"</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="youtube.com/watch?v=... or song name"
|
||||
prop:value=url_input
|
||||
on:input=move |ev| set_url_input.set(event_target_value(&ev))
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
style="width:100%"
|
||||
on:click=handle_queue_click
|
||||
disabled=move || url_input.get().is_empty()
|
||||
>
|
||||
"Queue Music"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::media::MediaState;
|
||||
|
||||
/// NowPlaying — Displays current media item and queue info
|
||||
/// Accepts an optional RwSignal to enable real-time updates from WebSocket events.
|
||||
#[component]
|
||||
pub fn NowPlaying(
|
||||
#[prop(optional)] media_rw: Option<RwSignal<Option<MediaState>>>,
|
||||
#[prop(optional)] on_skip: Option<Box<dyn Fn() + Send + Sync + 'static>>,
|
||||
#[prop(optional)] on_stop: Option<Box<dyn Fn() + Send + Sync + 'static>>,
|
||||
) -> impl IntoView {
|
||||
// Use provided signal, or fall back to a local one for static usage
|
||||
let media_state = media_rw.unwrap_or_else(|| RwSignal::new(None));
|
||||
|
||||
// Wrap callbacks in StoredValue for shareable non-Clone ownership in Leptos context
|
||||
let skip_cb = StoredValue::new(on_skip);
|
||||
let stop_cb = StoredValue::new(on_stop);
|
||||
let has_skip = skip_cb.with_value(|v| v.is_some());
|
||||
let has_stop = stop_cb.with_value(|v| v.is_some());
|
||||
|
||||
view! {
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">"Now Playing"</div>
|
||||
</div>
|
||||
<div class="card-content np-body">
|
||||
{move || {
|
||||
media_state.get().map(|ms| {
|
||||
let current = ms.current.as_ref().cloned();
|
||||
let queue_len = ms.queue.len();
|
||||
|
||||
view! {
|
||||
<>
|
||||
{current.map(|item| {
|
||||
let title = item.title.clone().unwrap_or_else(|| "Unknown".to_string());
|
||||
let duration_ms = item.duration_ms.unwrap_or(0);
|
||||
let duration_sec = duration_ms / 1000;
|
||||
view! {
|
||||
<div class="np-body">
|
||||
<div class="text-sm font-medium text-foreground truncate">
|
||||
{title}
|
||||
</div>
|
||||
<div class="np-meta">
|
||||
<span>{format!("{}s", duration_sec)}</span>
|
||||
</div>
|
||||
<div class="np-tags">
|
||||
{has_skip.then(|| {
|
||||
view! {
|
||||
<button
|
||||
class="btn btn-sm btn-outline flex-1"
|
||||
on:click=move |_| { skip_cb.with_value(|cb| { if let Some(cb) = cb { cb(); } }); }
|
||||
>
|
||||
"⏭ Skip"
|
||||
</button>
|
||||
}
|
||||
})}
|
||||
{has_stop.then(|| {
|
||||
view! {
|
||||
<button
|
||||
class="btn btn-sm btn-destructive flex-1"
|
||||
on:click=move |_| { stop_cb.with_value(|cb| { if let Some(cb) = cb { cb(); } }); }
|
||||
>
|
||||
"⏹ Stop"
|
||||
</button>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
|
||||
{(queue_len > 0).then(|| {
|
||||
view! {
|
||||
<div class="border-t border-border/50 pt-3">
|
||||
<div class="text-xs font-medium text-muted-foreground">
|
||||
{format!("Queue: {} item{}", queue_len, if queue_len == 1 { "" } else { "s" })}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
|
||||
{(queue_len == 0).then(|| {
|
||||
view! {
|
||||
<div class="text-xs text-muted-foreground text-center py-2">
|
||||
"Queue is empty"
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</>
|
||||
}
|
||||
})
|
||||
}}
|
||||
|
||||
{move || {
|
||||
media_state.get().is_none().then(|| {
|
||||
view! {
|
||||
<div class="text-xs text-muted-foreground text-center py-4">
|
||||
"No media connected"
|
||||
</div>
|
||||
}
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
use crate::api::recordings::{delete_recording, get_recordings};
|
||||
use leptos::prelude::*;
|
||||
use shared_types::recording::VoiceRecording;
|
||||
|
||||
/// RecordingsSubPanel — Paginated list of voice recordings
|
||||
/// Accepts an optional refresh_trigger signal to reload when a new recording is uploaded.
|
||||
#[component]
|
||||
pub fn RecordingsSubPanel(
|
||||
#[prop(optional)] refresh_trigger: Option<ReadSignal<u64>>,
|
||||
) -> impl IntoView {
|
||||
let recordings = RwSignal::new(Vec::<VoiceRecording>::new());
|
||||
let loading = RwSignal::new(false);
|
||||
let has_more = RwSignal::new(true);
|
||||
let next_cursor = RwSignal::new(None::<String>);
|
||||
|
||||
// Load recordings
|
||||
let load = move |reset: bool| {
|
||||
if loading.get_untracked() {
|
||||
return;
|
||||
}
|
||||
loading.set(true);
|
||||
|
||||
let cursor_val = if reset {
|
||||
None
|
||||
} else {
|
||||
next_cursor.get_untracked()
|
||||
};
|
||||
wasm_bindgen_futures::spawn_local({
|
||||
async move {
|
||||
match get_recordings(Some(20), cursor_val.as_deref()).await {
|
||||
Ok(resp) => {
|
||||
if reset {
|
||||
recordings.set(resp.items);
|
||||
} else {
|
||||
let mut current = recordings.get_untracked();
|
||||
current.extend(resp.items);
|
||||
recordings.set(current);
|
||||
}
|
||||
has_more.set(resp.has_more);
|
||||
next_cursor.set(resp.next_cursor);
|
||||
}
|
||||
Err(_) => {
|
||||
if reset {
|
||||
recordings.set(Vec::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Load on mount, and reload when refresh_trigger changes (e.g., new recording uploaded)
|
||||
Effect::new(move |_| {
|
||||
if let Some(trigger) = refresh_trigger {
|
||||
trigger.get(); // Track — re-run when WS signals a new recording
|
||||
}
|
||||
load(true);
|
||||
});
|
||||
|
||||
// Delete recording handler
|
||||
let do_delete = move |id: String| {
|
||||
wasm_bindgen_futures::spawn_local({
|
||||
let id = id.clone();
|
||||
async move {
|
||||
let _ = delete_recording(&id).await;
|
||||
recordings.update(|r| r.retain(|rec| rec.id != id));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="recordings-sub-panel card">
|
||||
<div class="card-header">
|
||||
<div class="card-title flex items-center gap-2">
|
||||
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
|
||||
<line x1="12" y1="19" x2="12" y2="23"></line>
|
||||
<line x1="8" y1="23" x2="16" y2="23"></line>
|
||||
</svg>
|
||||
"Recordings"
|
||||
</div>
|
||||
<p class="card-description">"Voice channel recordings from all sessions."</p>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
{move || {
|
||||
let recs = recordings.get();
|
||||
if recs.is_empty() && !loading.get() {
|
||||
view! {
|
||||
<div class="rec-empty">
|
||||
<p style="font-size:0.875rem;color:var(--text-secondary)">"No recordings yet."</p>
|
||||
<p style="font-size:0.75rem;color:var(--text-secondary)">"Join a voice channel to start recording."</p>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<div class="rec-list">
|
||||
{recs.iter().map(|rec| {
|
||||
let id = rec.id.clone();
|
||||
let username = rec.username.clone();
|
||||
let channel_name = rec.channel_name.clone().unwrap_or_default();
|
||||
let created_at = format_timestamp(rec.created_at);
|
||||
let has_url = rec.download_url.is_some();
|
||||
let url = rec.download_url.clone().unwrap_or_default();
|
||||
|
||||
view! {
|
||||
<div class="rec-item">
|
||||
<div class="rec-info">
|
||||
<div class="rec-name">{username}</div>
|
||||
<div class="rec-meta">
|
||||
<span>{channel_name}</span>
|
||||
<span>"·"</span>
|
||||
<span>{format_size(rec.size_bytes)}</span>
|
||||
<span>"·"</span>
|
||||
<span>{created_at}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rec-actions">
|
||||
{has_url.then(|| {
|
||||
view! {
|
||||
<a
|
||||
href=url
|
||||
target="_blank"
|
||||
class="btn btn-sm btn-outline"
|
||||
>
|
||||
"Download"
|
||||
</a>
|
||||
}
|
||||
})}
|
||||
<button
|
||||
class="btn btn-sm btn-ghost text-destructive hover:text-destructive"
|
||||
on:click=move |_| do_delete(id.clone())
|
||||
>
|
||||
"🗑"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
}}
|
||||
|
||||
{move || {
|
||||
(has_more.get() && !loading.get()).then(|| {
|
||||
view! {
|
||||
<div class="rec-footer">
|
||||
<button
|
||||
class="btn btn-sm btn-outline"
|
||||
on:click=move |_| load(false)
|
||||
>
|
||||
"Load more"
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Format file size bytes to human readable
|
||||
fn format_size(bytes: u64) -> String {
|
||||
if bytes < 1024 {
|
||||
format!("{} B", bytes)
|
||||
} else if bytes < 1024 * 1024 {
|
||||
format!("{:.1} KB", bytes as f64 / 1024.0)
|
||||
} else {
|
||||
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Format timestamp i64 to readable date
|
||||
fn format_timestamp(ts: i64) -> String {
|
||||
let d = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64((ts as f64) * 1000.0));
|
||||
d.to_locale_date_string("en-US", &wasm_bindgen::JsValue::UNDEFINED)
|
||||
.into()
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// ScreenSubPanel — Screenshare controls
|
||||
#[component]
|
||||
pub fn ScreenSubPanel(
|
||||
#[prop(optional)] on_start_stream: Option<Box<dyn Fn() + Send + Sync + 'static>>,
|
||||
#[prop(optional)] on_stop_stream: Option<Box<dyn Fn() + Send + Sync + 'static>>,
|
||||
) -> impl IntoView {
|
||||
let (is_streaming, set_is_streaming) = signal::<bool>(false);
|
||||
|
||||
let has_start = on_start_stream.is_some();
|
||||
let has_stop = on_stop_stream.is_some();
|
||||
|
||||
view! {
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title" style="display:flex;align-items:center;gap:var(--space-2)">
|
||||
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
|
||||
<line x1="8" y1="21" x2="16" y2="21"></line>
|
||||
<line x1="12" y1="17" x2="12" y2="21"></line>
|
||||
</svg>
|
||||
"Screenshare"
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
||||
<p style="font-size:0.75rem;color:var(--text-secondary)">
|
||||
"Stream your screen to the voice channel for everyone to see."
|
||||
</p>
|
||||
|
||||
<div class="scrn-actions">
|
||||
{has_start.then(|| {
|
||||
view! {
|
||||
<button
|
||||
class=move || format!("btn btn-success flex-1 {}", if is_streaming.get() { "opacity-50" } else { "" })
|
||||
disabled=move || is_streaming.get()
|
||||
on:click=move |_| {
|
||||
if !is_streaming.get_untracked() {
|
||||
set_is_streaming.set(true);
|
||||
if let Some(ref cb) = on_start_stream {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
"🔴 Start Stream"
|
||||
</button>
|
||||
}
|
||||
})}
|
||||
|
||||
{has_stop.then(|| {
|
||||
view! {
|
||||
<button
|
||||
class=move || format!("btn btn-destructive flex-1 {}", if !is_streaming.get() { "opacity-50" } else { "" })
|
||||
disabled=move || !is_streaming.get()
|
||||
on:click=move |_| {
|
||||
if is_streaming.get_untracked() {
|
||||
set_is_streaming.set(false);
|
||||
if let Some(ref cb) = on_stop_stream {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
"⏹ Stop Stream"
|
||||
</button>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
|
||||
{move || {
|
||||
is_streaming.get().then(|| {
|
||||
view! {
|
||||
<div class="scrn-status">
|
||||
"🔴 Live streaming..."
|
||||
</div>
|
||||
}
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
use crate::features::live::hooks::use_voice_control::{use_voice_control, VoiceControlState};
|
||||
use leptos::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
/// VoiceConnectionCard component for Leptos
|
||||
/// Renders guild and voice channel selectors with connect/disconnect controls
|
||||
#[component]
|
||||
pub fn VoiceConnectionCard(
|
||||
#[prop(optional)] voice_state: Option<VoiceControlState>,
|
||||
#[prop(optional)] class: &'static str,
|
||||
) -> impl IntoView {
|
||||
let default_state = use_voice_control();
|
||||
let state = voice_state.unwrap_or(default_state);
|
||||
|
||||
// Reactive signal for selected guild
|
||||
let (selected_guild, set_selected_guild) = signal::<String>(String::new());
|
||||
// Reactive signal for selected channel
|
||||
let (selected_channel, set_selected_channel) = signal::<String>(String::new());
|
||||
|
||||
// When guild is selected, load voice channels
|
||||
Effect::new(move |_| {
|
||||
let guild_id = selected_guild.get();
|
||||
if !guild_id.is_empty() {
|
||||
(state.load_voice_channels)(guild_id);
|
||||
}
|
||||
});
|
||||
|
||||
// Load guilds on mount
|
||||
Effect::new(move |_| {
|
||||
(state.load_guilds)();
|
||||
});
|
||||
|
||||
let on_guild_change = move |ev: leptos::ev::Event| {
|
||||
if let Some(target) = ev.target() {
|
||||
if let Ok(select_el) = target.dyn_into::<web_sys::HtmlSelectElement>() {
|
||||
set_selected_guild.set(select_el.value());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let on_channel_change = move |ev: leptos::ev::Event| {
|
||||
if let Some(target) = ev.target() {
|
||||
if let Ok(select_el) = target.dyn_into::<web_sys::HtmlSelectElement>() {
|
||||
set_selected_channel.set(select_el.value());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let on_join_click = move |_| {
|
||||
let guild_id = selected_guild.get();
|
||||
let channel_id = selected_channel.get();
|
||||
if !guild_id.is_empty() && !channel_id.is_empty() {
|
||||
(state.join_voice)(guild_id, channel_id);
|
||||
}
|
||||
};
|
||||
|
||||
let on_disconnect_click = move |_| {
|
||||
(state.leave_voice)();
|
||||
};
|
||||
|
||||
// Read signals for reactive rendering
|
||||
let guilds = state.guilds;
|
||||
let voice_channels = state.voice_channels;
|
||||
let loading = state.loading;
|
||||
let error = state.error;
|
||||
let voice_status = state.voice_status;
|
||||
|
||||
let is_connected = move || voice_status.get().map(|s| s.connected).unwrap_or(false);
|
||||
|
||||
let can_join = move || {
|
||||
!selected_guild.get().is_empty() && !selected_channel.get().is_empty() && !loading.get()
|
||||
};
|
||||
|
||||
let can_disconnect = move || is_connected() && !loading.get();
|
||||
|
||||
view! {
|
||||
<div class=format!("rounded-xl border border-border bg-card shadow-sm {}", class)>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<svg
|
||||
class="h-5 w-5 text-primary"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold tracking-tight">"Voice Bridge"</h3>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
"Join a Discord voice channel, listen, and transmit audio."
|
||||
</p>
|
||||
|
||||
{/* Guild and Channel Selectors */}
|
||||
<div class="grid gap-4 md:grid-cols-2 mb-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-foreground">"Guild"</label>
|
||||
<select
|
||||
prop:value=selected_guild
|
||||
on:change=on_guild_change
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">"Select guild"</option>
|
||||
<For each=move || guilds.get() key=|g| g.id.clone() let:guild>
|
||||
<option value=guild.id.clone()>
|
||||
{guild.name.clone()}
|
||||
</option>
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-foreground">"Voice Channel"</label>
|
||||
<select
|
||||
prop:value=selected_channel
|
||||
on:change=on_channel_change
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">"Select voice channel"</option>
|
||||
<For each=move || voice_channels.get() key=|c| c.id.clone() let:channel>
|
||||
<option value=channel.id.clone()>
|
||||
{channel.name.clone()}
|
||||
</option>
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{move || {
|
||||
error.get().map(|err| {
|
||||
view! {
|
||||
<div class="rounded-md bg-destructive/15 px-3 py-2 text-sm text-destructive mb-4">
|
||||
{err}
|
||||
</div>
|
||||
}
|
||||
})
|
||||
}}
|
||||
|
||||
{/* Status Display */}
|
||||
{move || {
|
||||
voice_status.get().map(|status| {
|
||||
let connected = status.connected;
|
||||
let active_channel = status.active_channel_name.clone();
|
||||
view! {
|
||||
<div class="flex items-center gap-2 text-sm mb-4">
|
||||
<div class=move || {
|
||||
if connected {
|
||||
"h-2 w-2 rounded-full bg-emerald-500"
|
||||
} else {
|
||||
"h-2 w-2 rounded-full bg-muted-foreground/40"
|
||||
}
|
||||
}></div>
|
||||
<span class=move || {
|
||||
if connected {
|
||||
"text-emerald-600 dark:text-emerald-400 font-medium"
|
||||
} else {
|
||||
"text-muted-foreground"
|
||||
}
|
||||
}>
|
||||
{if connected { "Connected" } else { "Disconnected" }}
|
||||
</span>
|
||||
{active_channel.map(|name| {
|
||||
view! {
|
||||
<span class="text-muted-foreground">
|
||||
{format!(" - {}", name)}
|
||||
</span>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
})
|
||||
}}
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class=move || {
|
||||
if can_join() {
|
||||
"btn btn-primary"
|
||||
} else {
|
||||
"btn btn-primary opacity-50 cursor-not-allowed"
|
||||
}
|
||||
}
|
||||
disabled=move || !can_join()
|
||||
on:click=on_join_click
|
||||
>
|
||||
{move || if is_connected() { "Reconnect" } else { "Join Voice" }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class=move || {
|
||||
if can_disconnect() {
|
||||
"btn btn-destructive"
|
||||
} else {
|
||||
"btn btn-destructive opacity-50 cursor-not-allowed"
|
||||
}
|
||||
}
|
||||
disabled=move || !can_disconnect()
|
||||
on:click=on_disconnect_click
|
||||
>
|
||||
"Disconnect"
|
||||
</button>
|
||||
|
||||
{move || {
|
||||
if loading.get() {
|
||||
view! {
|
||||
<span class="inline-flex items-center px-3 py-2 text-sm text-muted-foreground">
|
||||
"Loading..."
|
||||
</span>
|
||||
}.into_any()
|
||||
} else {
|
||||
let _: () = view! { <></> };
|
||||
().into_any()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
/// WaveformPlayer — Audio player with waveform progress bar
|
||||
#[component]
|
||||
pub fn WaveformPlayer(
|
||||
audio_url: String,
|
||||
#[prop(default = "Recording".to_string())] title: String,
|
||||
) -> impl IntoView {
|
||||
let is_playing = RwSignal::new(false);
|
||||
let current_time = RwSignal::new(0.0);
|
||||
let duration = RwSignal::new(0.0);
|
||||
let audio_id = format!("audio_{}", audio_url);
|
||||
|
||||
// Clone audio_url for the audio element
|
||||
let audio_src = audio_url.clone();
|
||||
let audio_src_for_id = audio_src.clone();
|
||||
|
||||
let toggle_play = move |_| {
|
||||
let doc = web_sys::window().unwrap().document().unwrap();
|
||||
let audio_opt = doc.get_element_by_id(&format!("audio_{}", audio_src_for_id));
|
||||
if let Some(audio_el) = audio_opt {
|
||||
if let Ok(audio) = audio_el.dyn_into::<web_sys::HtmlAudioElement>() {
|
||||
if is_playing.get_untracked() {
|
||||
let _ = audio.pause();
|
||||
is_playing.set(false);
|
||||
} else {
|
||||
if audio.ended() {
|
||||
audio.set_current_time(0.0);
|
||||
}
|
||||
if audio.play().is_ok() {
|
||||
is_playing.set(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let _ = audio_url; // Mark as used for the audio_id
|
||||
|
||||
view! {
|
||||
<div class="wave-wrap">
|
||||
<audio
|
||||
id=audio_id.clone()
|
||||
preload="auto"
|
||||
src=audio_src
|
||||
style="display:none"
|
||||
on:timeupdate=move |ev| {
|
||||
if let Some(target) = ev.target() {
|
||||
if let Ok(audio) = target.dyn_into::<web_sys::HtmlAudioElement>() {
|
||||
let ct = audio.current_time();
|
||||
let dur = audio.duration();
|
||||
current_time.set(ct);
|
||||
if dur.is_finite() && dur > 0.0 {
|
||||
duration.set(dur);
|
||||
}
|
||||
if audio.ended() {
|
||||
is_playing.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
></audio>
|
||||
|
||||
<div class="wave-progress">
|
||||
<div
|
||||
class="wave-bar"
|
||||
style=move || format!("width: {}%", progress_pct(current_time.get(), duration.get()))
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="wave-info">
|
||||
<button
|
||||
class=move || format!("btn btn-sm {}", if is_playing.get() { "btn-secondary" } else { "btn-primary" })
|
||||
on:click=toggle_play
|
||||
>
|
||||
{move || if is_playing.get() { "⏸" } else { "▶" }}
|
||||
</button>
|
||||
|
||||
<div class="wave-time">
|
||||
<span>{move || format_time(current_time.get())}</span>
|
||||
<span class="wave-title">{title.clone()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn progress_pct(current: f64, dur: f64) -> f64 {
|
||||
if dur > 0.0 {
|
||||
(current / dur * 100.0).min(100.0)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn format_time(secs: f64) -> String {
|
||||
if !secs.is_finite() || secs < 0.0 {
|
||||
return "00:00".to_string();
|
||||
}
|
||||
let total = secs as u32;
|
||||
format!("{:02}:{:02}", total / 60, total % 60)
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
pub mod use_audio_playback;
|
||||
pub mod use_audio_transmit;
|
||||
pub mod use_media_control;
|
||||
pub mod use_voice_control;
|
||||
@@ -1,117 +0,0 @@
|
||||
use crate::features::live::audio::pcm_decoder::decode_pcm_frame;
|
||||
use crate::features::live::audio::ring_buffer::SharedRingBuffer;
|
||||
use leptos::prelude::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// AudioPlaybackState — Manages PCM audio playback from WebSocket binary frames
|
||||
#[derive(Clone)]
|
||||
pub struct AudioPlaybackState {
|
||||
/// Ring buffer for incoming PCM data
|
||||
pub buffer: SharedRingBuffer,
|
||||
/// Whether playback is active
|
||||
pub active: RwSignal<bool>,
|
||||
/// Volume level (0.0-1.0)
|
||||
pub volume: RwSignal<f64>,
|
||||
/// Abort flag to stop the playback loop (prevents leak on teardown)
|
||||
pub abort: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// Create and initialize audio playback state
|
||||
pub fn use_audio_playback() -> AudioPlaybackState {
|
||||
let buffer = SharedRingBuffer::new(44100 * 5); // 5 seconds at 44.1kHz
|
||||
let active = RwSignal::new(false);
|
||||
let volume = RwSignal::new(0.5);
|
||||
|
||||
AudioPlaybackState {
|
||||
buffer,
|
||||
active,
|
||||
volume,
|
||||
abort: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Process incoming binary data from WebSocket (PCM audio frame)
|
||||
/// Format: [u32 userId (4 bytes)][i16 samples (N bytes)]
|
||||
pub fn process_pcm_data(state: &AudioPlaybackState, data: Vec<u8>) {
|
||||
if let Some(frame) = decode_pcm_frame(&data) {
|
||||
state.buffer.write(&frame.samples);
|
||||
}
|
||||
}
|
||||
|
||||
/// Start consuming the ring buffer and playing through AudioContext
|
||||
/// The loop respects the abort flag in `AudioPlaybackState` for clean teardown.
|
||||
pub fn start_playback(state: &AudioPlaybackState) {
|
||||
if state.active.get_untracked() {
|
||||
return;
|
||||
}
|
||||
state.active.set(true);
|
||||
// Reset abort flag for a fresh start
|
||||
state.abort.store(false, Ordering::Relaxed);
|
||||
|
||||
let buffer = state.buffer.clone();
|
||||
let active = state.active;
|
||||
let abort = state.abort.clone();
|
||||
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let ctx = match web_sys::AudioContext::new() {
|
||||
Ok(ctx) => ctx,
|
||||
Err(_) => {
|
||||
active.set(false);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let ctx_ref = &ctx;
|
||||
let _ = ctx_ref.resume();
|
||||
|
||||
while active.get_untracked() && !abort.load(Ordering::Relaxed) {
|
||||
let available = buffer.available_samples();
|
||||
if available >= 4410 {
|
||||
// ~100ms worth at 44.1kHz
|
||||
let samples = buffer.read(4410);
|
||||
if !samples.is_empty() {
|
||||
play_samples(&ctx, &samples);
|
||||
}
|
||||
}
|
||||
let _ = gloo_timers::future::TimeoutFuture::new(50).await;
|
||||
}
|
||||
|
||||
let _ = ctx.close();
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop playback and clear buffer
|
||||
pub fn stop_playback(state: &AudioPlaybackState) {
|
||||
state.abort.store(true, Ordering::Relaxed);
|
||||
state.active.set(false);
|
||||
state.buffer.clear();
|
||||
}
|
||||
|
||||
/// Play a chunk of PCM samples through AudioContext using AudioBufferSourceNode
|
||||
fn play_samples(ctx: &web_sys::AudioContext, samples: &[f32]) {
|
||||
let frame_count = samples.len() as u32;
|
||||
let Ok(audio_buffer) = ctx.create_buffer(1, frame_count, ctx.sample_rate()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Write samples into the buffer channel
|
||||
let Ok(channel_data) = audio_buffer.get_channel_data(0) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let len = samples.len().min(channel_data.len());
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Copy samples directly to audio buffer channel
|
||||
let _ = audio_buffer.copy_to_channel(&samples[..len], 0);
|
||||
|
||||
// Create source and play
|
||||
if let Ok(source) = ctx.create_buffer_source() {
|
||||
source.set_buffer(Some(&audio_buffer));
|
||||
source.set_loop(false);
|
||||
let _ = source.start();
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
use wasm_bindgen::{JsCast, JsValue};
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use web_sys::{MediaStream, MediaStreamConstraints, MediaStreamTrack};
|
||||
|
||||
/// AudioTransmitState — Manages microphone capture state
|
||||
pub struct AudioTransmitState {
|
||||
pub active: RwSignal<bool>,
|
||||
pub stream: StoredValue<Option<MediaStream>>,
|
||||
}
|
||||
|
||||
/// Create microphone transmit state
|
||||
pub fn use_audio_transmit() -> AudioTransmitState {
|
||||
let active = RwSignal::new(false);
|
||||
let stream = StoredValue::new(None::<MediaStream>);
|
||||
AudioTransmitState { active, stream }
|
||||
}
|
||||
|
||||
/// Start microphone capture - requests getUserMedia and stores the stream
|
||||
pub fn start_transmit(state: &AudioTransmitState) {
|
||||
if state.active.get_untracked() {
|
||||
return;
|
||||
}
|
||||
state.active.set(true);
|
||||
|
||||
let constraints = MediaStreamConstraints::new();
|
||||
let _ = js_sys::Reflect::set(
|
||||
&constraints,
|
||||
&JsValue::from_str("audio"),
|
||||
&JsValue::from_bool(true),
|
||||
);
|
||||
|
||||
let window = match web_sys::window() {
|
||||
Some(w) => w,
|
||||
None => return,
|
||||
};
|
||||
let media_devices = match window.navigator().media_devices() {
|
||||
Ok(md) => md,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let promise = match media_devices.get_user_media_with_constraints(&constraints) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
// Clone signals before spawning async task to avoid reference escaping
|
||||
let active_signal = state.active;
|
||||
let stream_signal = state.stream;
|
||||
|
||||
spawn_local(async move {
|
||||
match wasm_bindgen_futures::JsFuture::from(promise).await {
|
||||
Ok(val) => {
|
||||
if let Ok(s) = val.dyn_into::<MediaStream>() {
|
||||
stream_signal.set_value(Some(s));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
active_signal.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop microphone transmission
|
||||
pub fn stop_transmit(state: &AudioTransmitState) {
|
||||
state.active.set(false);
|
||||
state.stream.update_value(|s| {
|
||||
if let Some(stream) = s.take() {
|
||||
let tracks = stream.get_tracks();
|
||||
for i in 0..tracks.length() {
|
||||
let track_val = tracks.get(i);
|
||||
if let Ok(track) = track_val.dyn_into::<MediaStreamTrack>() {
|
||||
track.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
use crate::api::voice::{get_media_status, media_queue, media_skip, media_stop, media_volume};
|
||||
use leptos::prelude::*;
|
||||
use shared_types::media::MediaState;
|
||||
use std::sync::Arc;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
/// Callback type for enqueue
|
||||
pub type EnqueueCallback = Arc<dyn Fn(String, String) + Send + Sync>;
|
||||
/// Callback type for skip_track
|
||||
pub type SkipTrackCallback = Arc<dyn Fn() + Send + Sync>;
|
||||
/// Callback type for stop_playback
|
||||
pub type StopPlaybackCallback = Arc<dyn Fn() + Send + Sync>;
|
||||
/// Callback type for set_volume
|
||||
pub type SetVolumeCallback = Arc<dyn Fn(f64) + Send + Sync>;
|
||||
/// Callback type for refresh
|
||||
pub type RefreshCallback = Arc<dyn Fn() + Send + Sync>;
|
||||
|
||||
/// State returned by use_media_control hook
|
||||
#[derive(Clone)]
|
||||
pub struct MediaControlState {
|
||||
/// Current media playback state
|
||||
pub media_state: RwSignal<Option<MediaState>>,
|
||||
/// Whether we're currently loading data
|
||||
pub loading: RwSignal<bool>,
|
||||
/// Last error message if any
|
||||
pub error: RwSignal<Option<String>>,
|
||||
/// Enqueue media (source URL, mode: "music" or "screen")
|
||||
pub enqueue: EnqueueCallback,
|
||||
/// Skip to next track
|
||||
pub skip_track: SkipTrackCallback,
|
||||
/// Stop all playback
|
||||
pub stop_playback: StopPlaybackCallback,
|
||||
/// Set volume level (0.0 - 1.0)
|
||||
pub set_volume: SetVolumeCallback,
|
||||
/// Refresh media status from server
|
||||
pub refresh: RefreshCallback,
|
||||
}
|
||||
|
||||
/// Hook to manage media playback state and controls
|
||||
pub fn use_media_control() -> MediaControlState {
|
||||
// Core signals
|
||||
let media_state_signal = RwSignal::new(None::<MediaState>);
|
||||
let loading_signal = RwSignal::new(false);
|
||||
let error_signal = RwSignal::new(None::<String>);
|
||||
|
||||
// Enqueue media
|
||||
let enqueue_impl = Arc::new(move |source: String, mode: String| {
|
||||
spawn_local({
|
||||
let source = source.clone();
|
||||
let mode = mode.clone();
|
||||
async move {
|
||||
error_signal.set(None);
|
||||
loading_signal.set(true);
|
||||
|
||||
match media_queue(&source, &mode).await {
|
||||
Ok(state) => {
|
||||
media_state_signal.set(Some(state));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error_signal.set(Some(format!("Failed to enqueue media: {}", e)));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Skip to next track
|
||||
let skip_track_impl = Arc::new(move || {
|
||||
spawn_local(async move {
|
||||
error_signal.set(None);
|
||||
loading_signal.set(true);
|
||||
|
||||
match media_skip().await {
|
||||
Ok(state) => {
|
||||
media_state_signal.set(Some(state));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error_signal.set(Some(format!("Failed to skip track: {}", e)));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Stop all playback
|
||||
let stop_playback_impl = Arc::new(move || {
|
||||
spawn_local(async move {
|
||||
error_signal.set(None);
|
||||
loading_signal.set(true);
|
||||
|
||||
match media_stop().await {
|
||||
Ok(state) => {
|
||||
media_state_signal.set(Some(state));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error_signal.set(Some(format!("Failed to stop playback: {}", e)));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Set volume level
|
||||
let set_volume_impl = Arc::new(move |volume: f64| {
|
||||
spawn_local(async move {
|
||||
error_signal.set(None);
|
||||
loading_signal.set(true);
|
||||
|
||||
match media_volume(volume).await {
|
||||
Ok(state) => {
|
||||
media_state_signal.set(Some(state));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error_signal.set(Some(format!("Failed to set volume: {}", e)));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Refresh media status
|
||||
let refresh_impl = Arc::new(move || {
|
||||
spawn_local(async move {
|
||||
error_signal.set(None);
|
||||
loading_signal.set(true);
|
||||
|
||||
match get_media_status().await {
|
||||
Ok(state) => {
|
||||
media_state_signal.set(Some(state));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error_signal.set(Some(format!("Failed to refresh media status: {}", e)));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
MediaControlState {
|
||||
media_state: media_state_signal,
|
||||
loading: loading_signal,
|
||||
error: error_signal,
|
||||
enqueue: enqueue_impl,
|
||||
skip_track: skip_track_impl,
|
||||
stop_playback: stop_playback_impl,
|
||||
set_volume: set_volume_impl,
|
||||
refresh: refresh_impl,
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
use crate::api::voice::{
|
||||
connect_voice, disconnect_voice, get_guilds, get_text_channels, get_voice_channels,
|
||||
};
|
||||
use leptos::prelude::*;
|
||||
use shared_types::guild::{Channel, Guild};
|
||||
use shared_types::voice::VoiceStatus;
|
||||
use std::sync::Arc;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
/// Callback type for join_voice
|
||||
pub type JoinVoiceCallback = Arc<dyn Fn(String, String) + Send + Sync>;
|
||||
/// Callback type for leave_voice
|
||||
pub type LeaveVoiceCallback = Arc<dyn Fn() + Send + Sync>;
|
||||
/// Callback type for load_guilds
|
||||
pub type LoadGuildsCallback = Arc<dyn Fn() + Send + Sync>;
|
||||
/// Callback type for load_voice_channels
|
||||
pub type LoadVoiceChannelsCallback = Arc<dyn Fn(String) + Send + Sync>;
|
||||
/// Callback type for load_text_channels
|
||||
pub type LoadTextChannelsCallback = Arc<dyn Fn(String) + Send + Sync>;
|
||||
|
||||
/// State returned by use_voice_control hook
|
||||
#[derive(Clone)]
|
||||
pub struct VoiceControlState {
|
||||
/// List of available guilds
|
||||
pub guilds: RwSignal<Vec<Guild>>,
|
||||
/// List of voice channels for current guild
|
||||
pub voice_channels: RwSignal<Vec<Channel>>,
|
||||
/// List of text channels for current guild
|
||||
pub text_channels: RwSignal<Vec<Channel>>,
|
||||
/// Current voice connection status
|
||||
pub voice_status: RwSignal<Option<VoiceStatus>>,
|
||||
/// Whether we're currently loading data
|
||||
pub loading: RwSignal<bool>,
|
||||
/// Last error message if any
|
||||
pub error: RwSignal<Option<String>>,
|
||||
/// Join a voice channel
|
||||
pub join_voice: JoinVoiceCallback,
|
||||
/// Leave the current voice channel
|
||||
pub leave_voice: LeaveVoiceCallback,
|
||||
/// Fetch list of guilds
|
||||
pub load_guilds: LoadGuildsCallback,
|
||||
/// Fetch voice channels for a guild
|
||||
pub load_voice_channels: LoadVoiceChannelsCallback,
|
||||
/// Fetch text channels for a guild
|
||||
pub load_text_channels: LoadTextChannelsCallback,
|
||||
}
|
||||
|
||||
/// Hook to manage voice connection state and controls
|
||||
pub fn use_voice_control() -> VoiceControlState {
|
||||
// Core signals
|
||||
let guilds_signal = RwSignal::new(Vec::<Guild>::new());
|
||||
let voice_channels_signal = RwSignal::new(Vec::<Channel>::new());
|
||||
let text_channels_signal = RwSignal::new(Vec::<Channel>::new());
|
||||
let voice_status_signal = RwSignal::new(None::<VoiceStatus>);
|
||||
let loading_signal = RwSignal::new(false);
|
||||
let error_signal = RwSignal::new(None::<String>);
|
||||
|
||||
// Join a voice channel
|
||||
let join_voice_impl = Arc::new(move |guild_id: String, channel_id: String| {
|
||||
spawn_local({
|
||||
let guild_id = guild_id.clone();
|
||||
let channel_id = channel_id.clone();
|
||||
async move {
|
||||
error_signal.set(None);
|
||||
loading_signal.set(true);
|
||||
|
||||
match connect_voice(&guild_id, &channel_id).await {
|
||||
Ok(status) => {
|
||||
voice_status_signal.set(Some(status));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error_signal.set(Some(format!("Failed to join voice: {}", e)));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Leave the current voice channel
|
||||
let leave_voice_impl = Arc::new(move || {
|
||||
spawn_local(async move {
|
||||
error_signal.set(None);
|
||||
loading_signal.set(true);
|
||||
|
||||
match disconnect_voice().await {
|
||||
Ok(status) => {
|
||||
voice_status_signal.set(Some(status));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error_signal.set(Some(format!("Failed to leave voice: {}", e)));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch list of guilds
|
||||
let load_guilds_impl = Arc::new(move || {
|
||||
spawn_local(async move {
|
||||
error_signal.set(None);
|
||||
loading_signal.set(true);
|
||||
|
||||
match get_guilds().await {
|
||||
Ok(guilds) => {
|
||||
guilds_signal.set(guilds);
|
||||
loading_signal.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error_signal.set(Some(format!("Failed to load guilds: {}", e)));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch voice channels for a guild
|
||||
let load_voice_channels_impl = Arc::new(move |guild_id: String| {
|
||||
spawn_local({
|
||||
let guild_id = guild_id.clone();
|
||||
async move {
|
||||
error_signal.set(None);
|
||||
loading_signal.set(true);
|
||||
|
||||
match get_voice_channels(&guild_id).await {
|
||||
Ok(channels) => {
|
||||
voice_channels_signal.set(channels);
|
||||
loading_signal.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error_signal.set(Some(format!("Failed to load voice channels: {}", e)));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch text channels for a guild
|
||||
let load_text_channels_impl = Arc::new(move |guild_id: String| {
|
||||
spawn_local({
|
||||
let guild_id = guild_id.clone();
|
||||
async move {
|
||||
error_signal.set(None);
|
||||
loading_signal.set(true);
|
||||
|
||||
match get_text_channels(&guild_id).await {
|
||||
Ok(channels) => {
|
||||
text_channels_signal.set(channels);
|
||||
loading_signal.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error_signal.set(Some(format!("Failed to load text channels: {}", e)));
|
||||
loading_signal.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
VoiceControlState {
|
||||
guilds: guilds_signal,
|
||||
voice_channels: voice_channels_signal,
|
||||
text_channels: text_channels_signal,
|
||||
voice_status: voice_status_signal,
|
||||
loading: loading_signal,
|
||||
error: error_signal,
|
||||
join_voice: join_voice_impl,
|
||||
leave_voice: leave_voice_impl,
|
||||
load_guilds: load_guilds_impl,
|
||||
load_voice_channels: load_voice_channels_impl,
|
||||
load_text_channels: load_text_channels_impl,
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
pub mod audio;
|
||||
pub mod components;
|
||||
pub mod hooks;
|
||||
|
||||
use crate::app::AuthContext;
|
||||
use crate::auth::AuthOverlay;
|
||||
use crate::ws::context::WsContext;
|
||||
use components::{
|
||||
ActiveSpeakers, AudioVisualizer, MusicSubPanel, NowPlaying, RecordingsSubPanel, ScreenSubPanel,
|
||||
VoiceConnectionCard,
|
||||
};
|
||||
use leptos::prelude::*;
|
||||
use shared_types::media::MediaState;
|
||||
use shared_types::voice::ActiveSpeaker;
|
||||
use crate::{log_debug, log_info, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
/// LivePanel — Composition shell for all voice and media components.
|
||||
/// Shows an auth overlay if not authenticated, otherwise shows voice controls.
|
||||
#[component]
|
||||
pub fn LivePanel() -> impl IntoView {
|
||||
let auth = use_context::<AuthContext>().expect("AuthContext not provided");
|
||||
let ws = use_context::<WsContext>();
|
||||
|
||||
// ── Shared state for WS-driven components ──────────────
|
||||
let speakers = RwSignal::new(Vec::<ActiveSpeaker>::new());
|
||||
let media_state = RwSignal::new(None::<MediaState>);
|
||||
let (recordings_refresh, set_recordings_refresh) = signal(0u64);
|
||||
let audio_playback = hooks::use_audio_playback::use_audio_playback();
|
||||
|
||||
// ── Wire WS events (runs on mount, persists while LivePanel is active) ──
|
||||
if let Some(ref ws) = ws {
|
||||
log_info!("LivePanel wiring WS handlers");
|
||||
// Voice active user — update speakers list
|
||||
*ws.on_voice_active_user.borrow_mut() = Some(Box::new({
|
||||
let speakers = speakers.clone();
|
||||
move |speaker: ActiveSpeaker| {
|
||||
log_debug!("LivePanel voice_active_user: {}", speaker.user_id);
|
||||
speakers.update(|list| {
|
||||
if let Some(pos) = list.iter().position(|s| s.user_id == speaker.user_id) {
|
||||
list[pos] = speaker;
|
||||
} else {
|
||||
list.push(speaker);
|
||||
}
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
// Media state — update NowPlaying
|
||||
*ws.on_media_state.borrow_mut() = Some(Box::new({
|
||||
let ms = media_state.clone();
|
||||
move |state: MediaState| {
|
||||
log_debug!("LivePanel media_state received");
|
||||
ms.set(Some(state));
|
||||
}
|
||||
}));
|
||||
|
||||
// Recording uploaded — trigger recordings list refresh
|
||||
*ws.on_voice_recording_uploaded.borrow_mut() = Some(Box::new({
|
||||
let set_refresh = set_recordings_refresh;
|
||||
move |_recording| {
|
||||
log_debug!("LivePanel recording_uploaded received");
|
||||
set_refresh.update(|v| *v = v.wrapping_add(1));
|
||||
}
|
||||
}));
|
||||
|
||||
// Binary PCM data — process and play audio
|
||||
*ws.on_binary.borrow_mut() = Some(Box::new({
|
||||
let playback = audio_playback.clone();
|
||||
move |data: Vec<u8>| {
|
||||
log_debug!("LivePanel binary PCM data received: {} bytes", data.len());
|
||||
hooks::use_audio_playback::process_pcm_data(&playback, data);
|
||||
// Auto-start playback on first PCM data
|
||||
if !playback.active.get_untracked() {
|
||||
hooks::use_audio_playback::start_playback(&playback);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
view! {
|
||||
<div class="live-panel">
|
||||
{move || {
|
||||
if auth.authenticated.get() {
|
||||
view! {
|
||||
<div class="live-body">
|
||||
<div class="live-head">
|
||||
<div>
|
||||
<h2 class="live-title">"Voice & Media"</h2>
|
||||
<p class="live-desc">
|
||||
"Monitor voice channels, play music, share your screen, and browse recordings."
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top row: Voice connection + speakers + visualizer */}
|
||||
<div class="live-grid live-grid-3">
|
||||
<div class="live-span-2">
|
||||
<VoiceConnectionCard />
|
||||
</div>
|
||||
<div>
|
||||
<ActiveSpeakers speakers=speakers />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Audio visualization */}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">"Audio Visualization"</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<AudioVisualizer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Media controls: Now Playing + Music + Screen */}
|
||||
<div class="live-grid live-grid-3">
|
||||
<div>
|
||||
<NowPlaying media_rw=media_state />
|
||||
</div>
|
||||
<div>
|
||||
<MusicSubPanel />
|
||||
</div>
|
||||
<div>
|
||||
<ScreenSubPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recordings */}
|
||||
<RecordingsSubPanel refresh_trigger=recordings_refresh />
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! { <AuthOverlay /> }.into_any()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::message::MessageRecord;
|
||||
|
||||
#[component]
|
||||
pub fn ImageGrid(messages: Vec<MessageRecord>) -> impl IntoView {
|
||||
let mut seen_urls = std::collections::HashSet::new();
|
||||
let mut urls = Vec::new();
|
||||
|
||||
for msg in &messages {
|
||||
if let Some(meta) = &msg.metadata {
|
||||
// attachments with image MIME
|
||||
if let Some(atts) = &meta.attachments {
|
||||
for att in atts {
|
||||
let is_img = att
|
||||
.content_type
|
||||
.as_deref()
|
||||
.map(|ct| ct.starts_with("image/"))
|
||||
.unwrap_or(false)
|
||||
|| att.name.to_lowercase().ends_with(".png")
|
||||
|| att.name.to_lowercase().ends_with(".jpg")
|
||||
|| att.name.to_lowercase().ends_with(".jpeg")
|
||||
|| att.name.to_lowercase().ends_with(".gif")
|
||||
|| att.name.to_lowercase().ends_with(".webp");
|
||||
if is_img && seen_urls.insert(att.url.clone()) {
|
||||
urls.push(att.url.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// stickers
|
||||
if let Some(stickers) = &meta.stickers {
|
||||
for s in stickers {
|
||||
if let Some(ref url) = s.url {
|
||||
if seen_urls.insert(url.clone()) {
|
||||
urls.push(url.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// embed images
|
||||
if let Some(embeds) = &meta.embeds {
|
||||
for e in embeds {
|
||||
if let Some(ref img) = e.image {
|
||||
if seen_urls.insert(img.url.clone()) {
|
||||
urls.push(img.url.clone());
|
||||
}
|
||||
}
|
||||
if let Some(ref thumb) = e.thumbnail {
|
||||
if seen_urls.insert(thumb.url.clone()) {
|
||||
urls.push(thumb.url.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if urls.is_empty() {
|
||||
return view! {
|
||||
<div class="img-empty">
|
||||
"No images found"
|
||||
</div>
|
||||
}
|
||||
.into_any();
|
||||
}
|
||||
|
||||
view! {
|
||||
<div class="image-grid">
|
||||
{urls.into_iter().map(|url| {
|
||||
let url_clone = url.clone();
|
||||
view! {
|
||||
<a href=url_clone target="_blank" class="image-grid-item">
|
||||
<img src=url alt="attachment" loading="lazy" />
|
||||
</a>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::message::AiStatus;
|
||||
use std::sync::Arc;
|
||||
|
||||
// ─── Reanalyze Button ────────────────────────────────────
|
||||
#[component]
|
||||
pub fn ReanalyzeButton(
|
||||
message_id: String,
|
||||
ai_status: AiStatus,
|
||||
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
|
||||
) -> impl IntoView {
|
||||
let on_click_re = move |_| on_reanalyze(message_id.clone());
|
||||
view! {
|
||||
<div class="msg-actions">
|
||||
<button
|
||||
class=format!("btn btn-sm {}", if ai_status == AiStatus::Error { "btn-destructive" } else { "btn-outline" })
|
||||
on:click=on_click_re
|
||||
disabled=ai_status == AiStatus::Processing
|
||||
>
|
||||
<svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"></path><path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"></path></svg>
|
||||
" Re-analyze"
|
||||
</button>
|
||||
{(ai_status == AiStatus::Error).then(|| view! {
|
||||
<span class="msg-retry-hint">"Click to retry"</span>
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::message::{AiSeverity, AiStatus, AttachmentRef, MessageRecord, ReferenceInfo};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::message_actions::ReanalyzeButton;
|
||||
use super::message_embed::{MessageAnalysis, MessageError};
|
||||
use super::message_meta::{fmt_time, get_cats, is_fallback, render_emojis, severity_class, StatusBadgeInline, time_ago};
|
||||
|
||||
// ─── MessageRow ───────────────────────────────────────────
|
||||
#[component]
|
||||
pub fn MessageRow(
|
||||
message: MessageRecord,
|
||||
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
|
||||
) -> impl IntoView {
|
||||
let cats = get_cats(&message.ai_categories);
|
||||
let conf = message.ai_confidence.or(message.ai_moderation_score);
|
||||
let display = message
|
||||
.edited_content
|
||||
.as_deref()
|
||||
.unwrap_or(&message.content);
|
||||
let show = !display.is_empty() && !is_fallback(display);
|
||||
let ai_st = message.ai_status.clone().unwrap_or(AiStatus::Pending);
|
||||
|
||||
let analysis_summary = {
|
||||
let mut p = cats.iter().take(3).cloned().collect::<Vec<_>>().join(", ");
|
||||
if cats.len() > 3 {
|
||||
p = format!("{} +{} more", p, cats.len() - 3);
|
||||
}
|
||||
if !p.is_empty() {
|
||||
p.push_str(" · ");
|
||||
}
|
||||
p.push_str(&format!(
|
||||
"{}% conf",
|
||||
conf.map(|c| (c * 100.0) as u8).unwrap_or(0)
|
||||
));
|
||||
p
|
||||
};
|
||||
|
||||
// Attachments
|
||||
let all_atts = message
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.attachments.as_ref())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let imgs: Vec<AttachmentRef> = all_atts
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
a.content_type
|
||||
.as_deref()
|
||||
.map(|ct| ct.starts_with("image/"))
|
||||
.unwrap_or(false)
|
||||
|| a.name.to_lowercase().ends_with(".png")
|
||||
|| a.name.to_lowercase().ends_with(".jpg")
|
||||
|| a.name.to_lowercase().ends_with(".jpeg")
|
||||
|| a.name.to_lowercase().ends_with(".gif")
|
||||
|| a.name.to_lowercase().ends_with(".webp")
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
let vids: Vec<AttachmentRef> = all_atts
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
a.content_type
|
||||
.as_deref()
|
||||
.map(|ct| ct.starts_with("video/"))
|
||||
.unwrap_or(false)
|
||||
|| a.name.to_lowercase().ends_with(".mp4")
|
||||
|| a.name.to_lowercase().ends_with(".webm")
|
||||
|| a.name.to_lowercase().ends_with(".mov")
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let stickers = message
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.stickers.as_ref())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
// Extract reply info before view! to avoid closure capture issues
|
||||
let reply_el = message.is_reply.unwrap_or(false).then(|| {
|
||||
message
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.reference.as_ref())
|
||||
.map(|ref_info| {
|
||||
let r_user = ref_info.replied_username.as_deref().unwrap_or("unknown").to_string();
|
||||
let r_content = ref_info
|
||||
.content
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
(r_user, r_content)
|
||||
})
|
||||
});
|
||||
let reply_user = reply_el.as_ref().map(|r| r.as_ref().map(|(u, _)| u.clone()));
|
||||
let reply_user = reply_user.flatten();
|
||||
let reply_content = reply_el.as_ref().map(|r| r.as_ref().map(|(_, c)| c.clone()));
|
||||
let reply_content = reply_content.flatten();
|
||||
let reply_content_snippet = reply_content.as_ref().map(|c| {
|
||||
if c.len() > 48 {
|
||||
format!("{}…", &c[..48])
|
||||
} else {
|
||||
c.clone()
|
||||
}
|
||||
});
|
||||
let reply_initial = reply_user
|
||||
.as_ref()
|
||||
.and_then(|u| u.chars().next())
|
||||
.map(|c| c.to_uppercase().to_string())
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
|
||||
view! {
|
||||
<div class="msg-row">
|
||||
{/* Header */}
|
||||
<div class="msg-row-bar">
|
||||
<span class="msg-row-time" title=time_ago(message.created_at)>
|
||||
{fmt_time(message.created_at)}
|
||||
</span>
|
||||
{message.edited_at.is_some().then(|| view! {
|
||||
<span class="msg-row-badge edited">
|
||||
"✎ edited"
|
||||
</span>
|
||||
})}
|
||||
{message.deleted_at.is_some().then(|| view! {
|
||||
<span class="msg-row-badge deleted">
|
||||
"🗑 deleted"
|
||||
</span>
|
||||
})}
|
||||
<div class="msg-row-status">
|
||||
<StatusBadgeInline status=ai_st.clone() />
|
||||
{message.ai_severity.as_ref().filter(|s| **s != AiSeverity::None).map(|sev| view! {
|
||||
<span class=format!("badge text-xs {}", severity_class(sev))>{format!("{:?}", sev)}</span>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reply indicator — Discord-style reference block */}
|
||||
{reply_user.as_ref().map(|r_user| {
|
||||
let r_user_cloned = r_user.clone();
|
||||
let snippet = reply_content_snippet.clone();
|
||||
let initial = reply_initial.clone();
|
||||
view! {
|
||||
<div class="msg-row-reply">
|
||||
<div class="msg-row-reply-line"></div>
|
||||
<div class="msg-row-reply-main">
|
||||
<span class="msg-row-reply-avatar">{initial}</span>
|
||||
<span class="msg-row-reply-label">"Replying to"</span>
|
||||
<span class="msg-row-reply-user">"@" {r_user_cloned}</span>
|
||||
{(!snippet.as_deref().unwrap_or("").is_empty()).then(|| {
|
||||
let s = snippet.unwrap_or_default();
|
||||
view! {
|
||||
<span class="msg-row-reply-snippet">{s}</span>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
|
||||
{/* Content */}
|
||||
{show.then(|| {
|
||||
let rendered = render_emojis(display);
|
||||
view! {
|
||||
<p class="msg-row-body" class:is-deleted=message.deleted_at.is_some()>
|
||||
{rendered.into_iter().collect::<Vec<_>>()}
|
||||
</p>
|
||||
}
|
||||
})}
|
||||
|
||||
{/* Stickers */}
|
||||
{(!stickers.is_empty()).then(|| view! {
|
||||
<div class="msg-media-row">
|
||||
{stickers.iter().map(|s| {
|
||||
let url_owned = s.url.clone().unwrap_or_default();
|
||||
let name_owned = s.name.clone().unwrap_or_default();
|
||||
let has_url = !url_owned.is_empty();
|
||||
view! {
|
||||
<div>
|
||||
{if has_url {
|
||||
view! {
|
||||
<img src=url_owned alt=name_owned class="msg-sticker" loading="lazy" />
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<div class="msg-sticker-placeholder">
|
||||
"😊"
|
||||
</div>
|
||||
}.into_any()
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
})}
|
||||
|
||||
{/* Images */}
|
||||
{if !imgs.is_empty() {
|
||||
let imgs_local = imgs.clone();
|
||||
let images_view = imgs_local.iter().take(4).map(|a| {
|
||||
let url1 = a.url.clone();
|
||||
let url2 = a.url.clone();
|
||||
let name1 = a.name.clone();
|
||||
view! {
|
||||
<a href=url1 target="_blank" class="msg-thumb-link">
|
||||
<img src=url2 alt=name1 class="msg-thumb" loading="lazy" />
|
||||
</a>
|
||||
}
|
||||
}).collect::<Vec<_>>();
|
||||
let overflow = if imgs.len() > 4 {
|
||||
let extra = imgs.len() - 4;
|
||||
view! {
|
||||
<div class="msg-media-overflow">
|
||||
<span>{"+"} {extra}</span> <span class="ml-0.5">"🖼"</span>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
};
|
||||
view! {
|
||||
<div class="msg-media-row is-scroll">
|
||||
{images_view}
|
||||
{overflow}
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
}}
|
||||
|
||||
{/* Videos */}
|
||||
{if !vids.is_empty() {
|
||||
let vids_local = vids.clone();
|
||||
let videos_view = vids_local.iter().take(4).map(|a| {
|
||||
let url = a.url.clone();
|
||||
view! {
|
||||
<video src=url controls class="msg-video" preload="metadata"></video>
|
||||
}
|
||||
}).collect::<Vec<_>>();
|
||||
let overflow = if vids.len() > 4 {
|
||||
let extra = vids.len() - 4;
|
||||
view! {
|
||||
<div class="msg-media-overflow tall">
|
||||
<span>{"+"} {extra}</span> <span class="ml-0.5">"▶"</span>
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
};
|
||||
view! {
|
||||
<div class="msg-media-row is-scroll">
|
||||
{videos_view}
|
||||
{overflow}
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
}}
|
||||
|
||||
{/* Categories */}
|
||||
{if !cats.is_empty() {
|
||||
let cats_local = cats.clone();
|
||||
view! {
|
||||
<div class="msg-cats">
|
||||
{cats_local.iter().map(|c| view! {
|
||||
<span class="badge badge-secondary text-xs">{c.clone()}</span>
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
().into_any()
|
||||
}}
|
||||
|
||||
{/* AI Analysis */}
|
||||
{message.ai_analysis.as_ref().map(|analysis| {
|
||||
let analysis_summary_str = analysis_summary.clone();
|
||||
let analysis_str = analysis.clone();
|
||||
view! {
|
||||
<MessageAnalysis analysis=analysis_str ai_status=ai_st.clone() analysis_summary=analysis_summary_str />
|
||||
}
|
||||
})}
|
||||
|
||||
{/* Error */}
|
||||
{message.ai_error.as_ref().map(|e| {
|
||||
let error_str = e.clone();
|
||||
view! {
|
||||
<MessageError error=error_str />
|
||||
}
|
||||
})}
|
||||
|
||||
{/* Re-analyze */}
|
||||
<ReanalyzeButton message_id=message.id.clone() ai_status=ai_st.clone() on_reanalyze=on_reanalyze.clone() />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MessageCard ──────────────────────────────────────────
|
||||
#[component]
|
||||
pub fn MessageCard(
|
||||
messages: Vec<MessageRecord>,
|
||||
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
|
||||
) -> impl IntoView {
|
||||
let first = &messages[0];
|
||||
let has_multi = messages.len() > 1;
|
||||
let deleted = first.deleted_at.is_some();
|
||||
let avatar = first
|
||||
.avatar_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| "https://cdn.discordapp.com/embed/avatars/0.png".into());
|
||||
let loc_label = first
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.channel.as_ref())
|
||||
.map(|c| {
|
||||
if let Some(ref tn) = c.thread_name {
|
||||
format!("# {} › {}", c.channel_name.as_deref().unwrap_or("?"), tn)
|
||||
} else {
|
||||
format!("# {}", c.channel_name.as_deref().unwrap_or("?"))
|
||||
}
|
||||
});
|
||||
let card_cls = if deleted { " is-deleted" } else { "" };
|
||||
|
||||
view! {
|
||||
<article class=format!("msg-card{}", card_cls)>
|
||||
<div class="msg-card-inner">
|
||||
<img src=avatar alt="" class="msg-card-avatar" />
|
||||
<div class="msg-card-body">
|
||||
<div class="msg-card-head">
|
||||
<span class="msg-card-name">{first.username.clone()}</span>
|
||||
{loc_label.as_ref().map(|l| {
|
||||
let label_str = l.clone();
|
||||
view! {
|
||||
<span class="msg-card-channel">{label_str}</span>
|
||||
}
|
||||
})}
|
||||
<span class="msg-card-time">
|
||||
{time_ago(first.created_at)}
|
||||
{has_multi.then(|| format!(" · {} msgs", messages.len()))}
|
||||
</span>
|
||||
</div>
|
||||
<div class="msg-card-messages" class:separated=has_multi>
|
||||
{messages.into_iter().enumerate().map(|(_i, msg)| {
|
||||
view! {
|
||||
<MessageRow message=msg on_reanalyze=on_reanalyze.clone() />
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Skeleton ─────────────────────────────────────────────
|
||||
#[component]
|
||||
pub fn MessageCardSkeleton() -> impl IntoView {
|
||||
view! {
|
||||
<article class="msg-card">
|
||||
<div class="msg-skel">
|
||||
<div class="msg-skel-avatar"></div>
|
||||
<div class="msg-skel-lines">
|
||||
<div class="msg-skel-line" style="width:192px"></div>
|
||||
<div class="msg-skel-line" style="width:100%"></div>
|
||||
<div class="msg-skel-line" style="width:75%"></div>
|
||||
<div class="msg-skel-badges">
|
||||
<div class="msg-skel-badge" style="width:64px"></div>
|
||||
<div class="msg-skel-badge" style="width:80px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::message::AiStatus;
|
||||
|
||||
// ─── Message Analysis ─────────────────────────────────────
|
||||
#[component]
|
||||
pub fn MessageAnalysis(
|
||||
analysis: String,
|
||||
ai_status: AiStatus,
|
||||
analysis_summary: String,
|
||||
) -> impl IntoView {
|
||||
let f_cls = if ai_status == AiStatus::Flagged { "flagged" } else { "clean" };
|
||||
let icon = if ai_status == AiStatus::Flagged { "🚨" } else { "ℹ️" };
|
||||
view! {
|
||||
<div class=format!("msg-analysis {}", f_cls)>
|
||||
<div class="msg-analysis-row">
|
||||
<span class="msg-analysis-icon">{icon}</span>
|
||||
<div class="msg-analysis-body">
|
||||
<span class="msg-analysis-summary">{analysis_summary}</span>
|
||||
<div class="msg-analysis-text">{analysis}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Message Error ────────────────────────────────────────
|
||||
#[component]
|
||||
pub fn MessageError(
|
||||
error: String,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="msg-error">
|
||||
<span>"AI error: "{error}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
use leptos::html;
|
||||
use leptos::prelude::*;
|
||||
use shared_types::message::MessageRecord;
|
||||
use std::sync::Arc;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::IntersectionObserver;
|
||||
|
||||
|
||||
const GROUP_WINDOW_MS: i64 = 5 * 60 * 1000;
|
||||
|
||||
fn group_messages(messages: Vec<MessageRecord>) -> Vec<Vec<MessageRecord>> {
|
||||
let mut groups: Vec<Vec<MessageRecord>> = Vec::new();
|
||||
for msg in messages {
|
||||
if let Some(last_group) = groups.last_mut() {
|
||||
let same_user = last_group
|
||||
.first()
|
||||
.map(|m| m.user_id == msg.user_id)
|
||||
.unwrap_or(false);
|
||||
let same_window = last_group
|
||||
.last()
|
||||
.map(|m| (m.created_at - msg.created_at).abs() < GROUP_WINDOW_MS)
|
||||
.unwrap_or(false);
|
||||
if same_user && same_window {
|
||||
last_group.push(msg);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
groups.push(vec![msg]);
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn MessageFeed(
|
||||
messages: Vec<MessageRecord>,
|
||||
#[prop(optional)] empty_text: &'static str,
|
||||
#[prop(optional)] loading: bool,
|
||||
#[prop(optional)] has_more: bool,
|
||||
#[prop(optional)] loading_more: bool,
|
||||
#[prop(optional)] on_load_more: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
|
||||
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
|
||||
) -> impl IntoView {
|
||||
let sentinel_ref = NodeRef::<html::Div>::new();
|
||||
let (observer_ready, set_observer_ready) = signal(false);
|
||||
|
||||
// Schedule observer setup to run AFTER the DOM is mounted (next microtask).
|
||||
// With has_more, !loading, and messages present the sentinel div will be in the DOM.
|
||||
if !loading && !messages.is_empty() && has_more {
|
||||
wasm_bindgen_futures::spawn_local({
|
||||
let setter = set_observer_ready;
|
||||
async move {
|
||||
setter.set(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Clone before move into Effect closure so it's still available for the view
|
||||
let on_load_more_io = on_load_more.clone();
|
||||
Effect::new(move |_| {
|
||||
let _ready = observer_ready.get();
|
||||
if !_ready {
|
||||
return;
|
||||
}
|
||||
if let Some(node) = sentinel_ref.get() {
|
||||
let cb = on_load_more_io.clone();
|
||||
let observer_cb = Closure::<dyn Fn(Vec<JsValue>, IntersectionObserver)>::new(
|
||||
move |entries: Vec<JsValue>, _observer: IntersectionObserver| {
|
||||
for entry in entries {
|
||||
if let Some(entry) =
|
||||
entry.dyn_ref::<web_sys::IntersectionObserverEntry>()
|
||||
{
|
||||
if entry.is_intersecting() {
|
||||
if let Some(ref cb) = cb {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
let observer =
|
||||
IntersectionObserver::new(observer_cb.as_ref().unchecked_ref())
|
||||
.expect("IntersectionObserver failed");
|
||||
observer.observe(&node);
|
||||
// Keep closure alive — forget rather than cleanup since observer owns it
|
||||
observer_cb.forget();
|
||||
on_cleanup(move || {
|
||||
observer.disconnect();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Loading state
|
||||
if loading {
|
||||
return view! {
|
||||
<div class="flex flex-col gap-4">
|
||||
{std::iter::repeat_with(|| {
|
||||
use super::message_card::MessageCardSkeleton;
|
||||
view! { <MessageCardSkeleton /> }
|
||||
}).take(3).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}
|
||||
.into_any();
|
||||
}
|
||||
|
||||
if messages.is_empty() {
|
||||
return view! {
|
||||
<div class="feed-empty">
|
||||
<div class="feed-empty-title">
|
||||
{if empty_text.is_empty() { "No messages" } else { empty_text }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
.into_any();
|
||||
}
|
||||
|
||||
let groups = group_messages(messages);
|
||||
let has_more_val = has_more;
|
||||
let loading_more_val = loading_more;
|
||||
|
||||
view! {
|
||||
<div class="feed-wrap">
|
||||
{groups.into_iter().map(|group| {
|
||||
let cb = on_reanalyze.clone();
|
||||
view! {
|
||||
<MessageCardGroup messages=group on_reanalyze=cb.clone() />
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
|
||||
{/* Infinite scroll sentinel + fallback load more button */}
|
||||
{has_more_val.then(|| {
|
||||
view! {
|
||||
<>
|
||||
<div node_ref=sentinel_ref class="feed-sentinel">
|
||||
{loading_more_val.then(|| {
|
||||
use super::message_card::MessageCardSkeleton;
|
||||
view! { <MessageCardSkeleton /> }
|
||||
})}
|
||||
</div>
|
||||
{/* Fallback: visible button in case IntersectionObserver doesn't fire */}
|
||||
{(!loading_more_val).then(|| {
|
||||
let load_more_cb = on_load_more.clone();
|
||||
view! {
|
||||
<div class="feed-loader">
|
||||
<button
|
||||
class="btn btn-outline btn-sm"
|
||||
on:click=move |_| {
|
||||
if let Some(ref cb) = load_more_cb { cb(); }
|
||||
}
|
||||
>
|
||||
"Load more"
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
</>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn MessageCardGroup(
|
||||
messages: Vec<MessageRecord>,
|
||||
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
|
||||
) -> impl IntoView {
|
||||
use super::message_card::MessageCard;
|
||||
view! {
|
||||
<MessageCard messages=messages on_reanalyze=on_reanalyze />
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
use regex::Regex;
|
||||
use shared_types::message::{AiSeverity, AiStatus};
|
||||
use std::sync::OnceLock;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────
|
||||
|
||||
pub fn custom_emoji_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"<(a)?:([a-zA-Z0-9_]+):(\d+)>").unwrap())
|
||||
}
|
||||
|
||||
pub fn render_emojis(content: &str) -> Vec<AnyView> {
|
||||
let re = custom_emoji_regex();
|
||||
let mut parts: Vec<AnyView> = Vec::new();
|
||||
let mut last = 0;
|
||||
let content_owned = content.to_string();
|
||||
for cap in re.captures_iter(&content_owned) {
|
||||
let m = cap.get(0).unwrap();
|
||||
if m.start() > last {
|
||||
let text = content_owned[last..m.start()].to_string();
|
||||
parts.push(view! { <span>{text}</span> }.into_any());
|
||||
}
|
||||
let animated = cap.get(1).is_some();
|
||||
let name = cap.get(2).map(|c| c.as_str()).unwrap_or("").to_string();
|
||||
let id = cap.get(3).map(|c| c.as_str()).unwrap_or("0").to_string();
|
||||
let ext = if animated { "gif" } else { "png" };
|
||||
let url = format!("https://cdn.discordapp.com/emojis/{}.{}?size=128", id, ext);
|
||||
let title = format!(":{}:", name);
|
||||
parts.push(
|
||||
view! {
|
||||
<img src=url alt=name class="custom-emoji" title=title loading="lazy" />
|
||||
}
|
||||
.into_any(),
|
||||
);
|
||||
last = m.end();
|
||||
}
|
||||
if last < content_owned.len() {
|
||||
let text = content_owned[last..].to_string();
|
||||
parts.push(view! { <span>{text}</span> }.into_any());
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
pub fn time_ago(ts: i64) -> String {
|
||||
let now = js_sys::Date::now() as i64;
|
||||
// created_at is in milliseconds (from Discord's message.createdTimestamp)
|
||||
let secs = if now > ts { (now - ts) / 1000 } else { 0 };
|
||||
if secs < 60 {
|
||||
format!("{}s ago", secs)
|
||||
} else if secs < 3600 {
|
||||
format!("{}m ago", secs / 60)
|
||||
} else if secs < 86400 {
|
||||
format!("{}h ago", secs / 3600)
|
||||
} else {
|
||||
let d = js_sys::Date::new(&JsValue::from_f64(ts as f64));
|
||||
format!("{}", d.to_locale_date_string("en-US", &JsValue::UNDEFINED))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fmt_time(ts: i64) -> String {
|
||||
let d = js_sys::Date::new(&JsValue::from_f64(ts as f64));
|
||||
format!("{:02}:{:02}", d.get_hours(), d.get_minutes())
|
||||
}
|
||||
|
||||
pub fn severity_class(s: &AiSeverity) -> &'static str {
|
||||
match s {
|
||||
AiSeverity::Critical | AiSeverity::High => "badge-destructive",
|
||||
AiSeverity::Medium => "badge-warning",
|
||||
AiSeverity::Low => "badge-info",
|
||||
AiSeverity::None => "badge-outline",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_fallback(t: &str) -> bool {
|
||||
t.starts_with("[Attachment:") || t.starts_with("[Sticker:") || t.starts_with("[Embed]")
|
||||
}
|
||||
|
||||
pub fn get_cats(raw: &Option<Vec<String>>) -> Vec<String> {
|
||||
raw.as_ref()
|
||||
.map(|v| {
|
||||
v.iter()
|
||||
.filter(|c| *c != "analysis_incomplete")
|
||||
.cloned()
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ─── StatusBadgeInline ────────────────────────────────────
|
||||
#[component]
|
||||
pub fn StatusBadgeInline(status: AiStatus) -> impl IntoView {
|
||||
let (cl, icon_svg): (&'static str, AnyView) = match &status {
|
||||
AiStatus::Clean => ("status-badge-clean", view! { <svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M10 15.586L6.707 12.293a1 1 0 00-1.414 1.414l4 4a1 1 0 001.414 0l8-8a1 1 0 10-1.414-1.414L10 15.586z"></path></svg> }.into_any()),
|
||||
AiStatus::Flagged => ("status-badge-flagged", view! { <svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line></svg> }.into_any()),
|
||||
AiStatus::Error => ("status-badge-error", view! { <svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line></svg> }.into_any()),
|
||||
AiStatus::Pending => {
|
||||
("status-badge-pending", ().into_any())
|
||||
},
|
||||
AiStatus::Processing => {
|
||||
("status-badge-processing", ().into_any())
|
||||
},
|
||||
AiStatus::Warn => {
|
||||
("status-badge-warn", ().into_any())
|
||||
},
|
||||
};
|
||||
view! {
|
||||
<span class=format!("status-badge {}", cl)>
|
||||
{icon_svg}
|
||||
{format!("{:?}", status)}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
pub mod image_grid;
|
||||
pub mod message_card;
|
||||
pub mod message_embed;
|
||||
pub mod message_meta;
|
||||
pub mod message_actions;
|
||||
pub mod message_feed;
|
||||
@@ -1,135 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::message::MessageRecord;
|
||||
use std::sync::Arc;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use crate::{log_info, log_warn, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
type AiFilter = &'static str;
|
||||
const FILTERS: &[AiFilter] = &["all", "analyzed", "clean", "flagged", "error", "pending"];
|
||||
|
||||
#[component]
|
||||
pub fn FilterBar(
|
||||
search_query: ReadSignal<String>,
|
||||
set_search_query: WriteSignal<String>,
|
||||
show_search: ReadSignal<bool>,
|
||||
set_show_search: WriteSignal<bool>,
|
||||
is_searching: ReadSignal<bool>,
|
||||
set_is_searching: WriteSignal<bool>,
|
||||
set_search_results: WriteSignal<Vec<MessageRecord>>,
|
||||
ai_filter: RwSignal<String>,
|
||||
error_count: Memo<usize>,
|
||||
retrying_all: ReadSignal<bool>,
|
||||
set_retrying_all: WriteSignal<bool>,
|
||||
reanalyze_all_errors: Arc<dyn Fn() + Send + Sync>,
|
||||
) -> impl IntoView {
|
||||
// Search handler - takes any event type and triggers the search
|
||||
let do_search = {
|
||||
let q = search_query;
|
||||
move || {
|
||||
let query = q.get();
|
||||
if query.trim().is_empty() {
|
||||
set_show_search.set(false);
|
||||
set_search_results.set(Vec::new());
|
||||
return;
|
||||
}
|
||||
set_is_searching.set(true);
|
||||
let q_clone = query.trim().to_string();
|
||||
log_info!("Messages searching for: {}", q_clone);
|
||||
spawn_local(async move {
|
||||
match crate::api::messages::search_messages(&q_clone, Some(50)).await {
|
||||
Ok(results) => {
|
||||
log_info!("Messages search found {} results", results.len());
|
||||
set_search_results.set(results);
|
||||
set_show_search.set(true);
|
||||
}
|
||||
Err(_) => {
|
||||
log_warn!("Messages search failed");
|
||||
set_search_results.set(Vec::new());
|
||||
}
|
||||
}
|
||||
set_is_searching.set(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
// Separate closures for different event types so on:click/on:keydown type-check
|
||||
let handle_search_click = move |_: web_sys::MouseEvent| do_search();
|
||||
let handle_search_keydown = move |_: web_sys::KeyboardEvent| do_search();
|
||||
|
||||
// Clear search
|
||||
let clear_search = move |_| {
|
||||
set_show_search.set(false);
|
||||
set_search_results.set(Vec::new());
|
||||
set_search_query.set(String::new());
|
||||
};
|
||||
|
||||
// Filter chip click
|
||||
let set_filter = {
|
||||
let af = ai_filter;
|
||||
move |f: &'static str| af.set(f.to_string())
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="search-bar">
|
||||
<div class="search-wrap">
|
||||
<svg width="16" height="16" class="search-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.35-4.35"></path></svg>
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder="Search message content..."
|
||||
prop:value=search_query
|
||||
on:input=move |ev| set_search_query.set(event_target_value(&ev))
|
||||
on:keydown=move |ev| {
|
||||
if ev.key() == "Enter" { handle_search_keydown(ev); }
|
||||
}
|
||||
disabled=move || is_searching.get()
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
on:click=handle_search_click
|
||||
disabled=move || is_searching.get() || search_query.get().trim().is_empty()
|
||||
>
|
||||
{move || if is_searching.get() { "Searching..." } else { "Search" }}
|
||||
</button>
|
||||
{move || show_search.get().then(|| view! {
|
||||
<button class="btn btn-outline btn-sm" on:click=clear_search>
|
||||
"✕ Clear"
|
||||
</button>
|
||||
})}
|
||||
{move || {
|
||||
let err = error_count.get();
|
||||
(err > 0 && !show_search.get()).then(|| {
|
||||
let cb = reanalyze_all_errors.clone();
|
||||
let err_count = err;
|
||||
view! {
|
||||
<button
|
||||
class="btn btn-destructive btn-sm"
|
||||
on:click=move |_| {
|
||||
set_retrying_all.set(true);
|
||||
let cb = cb.clone();
|
||||
spawn_local(async move {
|
||||
cb();
|
||||
set_retrying_all.set(false);
|
||||
});
|
||||
}
|
||||
disabled=move || retrying_all.get()
|
||||
>
|
||||
<svg class=format!("mr-1.5 h-3.5 w-3.5{}", if retrying_all.get() { " icon-spin" } else { "" }) xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"></path><path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"></path></svg>
|
||||
{move || if retrying_all.get() { "Retrying...".to_string() } else { format!("Retry All Errors ({})", err_count) }}
|
||||
</button>
|
||||
}
|
||||
})
|
||||
}}
|
||||
<div class="filter-group">
|
||||
<svg width="16" height="16" style="color:var(--color-primary)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg>
|
||||
{FILTERS.iter().map(|f| {
|
||||
let f_ptr: &'static str = f;
|
||||
view! {
|
||||
<button class="filter-chip-v2" class:is-active=move || ai_filter.get() == f_ptr on:click=move |_| set_filter(f_ptr) >{*f}</button>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
pub mod use_messages;
|
||||
@@ -1,229 +0,0 @@
|
||||
use crate::api::messages::{get_messages, reanalyze_batch, reanalyze_message};
|
||||
use leptos::prelude::*;
|
||||
use shared_types::message::{MessageRecord, PageResult};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use crate::{log_debug, log_error, log_info, log_trace, log_warn, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
/// Merges current messages with incoming messages, deduplicating by ID and sorting
|
||||
pub fn merge_messages(current: &[MessageRecord], incoming: &[MessageRecord]) -> Vec<MessageRecord> {
|
||||
let mut by_id: HashMap<String, MessageRecord> =
|
||||
current.iter().map(|m| (m.id.clone(), m.clone())).collect();
|
||||
for msg in incoming {
|
||||
by_id.insert(msg.id.clone(), msg.clone());
|
||||
}
|
||||
let mut merged: Vec<MessageRecord> = by_id.into_values().collect();
|
||||
merged.sort_by(|a, b| {
|
||||
b.created_at
|
||||
.cmp(&a.created_at)
|
||||
.then_with(|| b.id.cmp(&a.id))
|
||||
});
|
||||
merged
|
||||
}
|
||||
|
||||
/// Callback type for fetch_messages
|
||||
pub type FetchMessagesCallback = Arc<dyn Fn(String) + Send + Sync>;
|
||||
/// Callback type for load_more
|
||||
pub type LoadMoreCallback = Arc<dyn Fn() + Send + Sync>;
|
||||
/// Callback type for reanalyze
|
||||
pub type ReanalyzeCallback = Arc<dyn Fn(String) + Send + Sync>;
|
||||
/// Callback type for reanalyze_all_errors
|
||||
pub type ReanalyzeAllErrorsCallback = Arc<dyn Fn() + Send + Sync>;
|
||||
|
||||
/// State returned by use_messages hook
|
||||
#[derive(Clone)]
|
||||
pub struct MessagesState {
|
||||
/// Current list of messages
|
||||
pub messages: RwSignal<Vec<MessageRecord>>,
|
||||
/// Whether the initial fetch is in progress
|
||||
pub loading: ReadSignal<bool>,
|
||||
/// Whether we're loading more messages
|
||||
pub loading_more: RwSignal<bool>,
|
||||
/// Pagination cursor for next page
|
||||
pub cursor: RwSignal<Option<String>>,
|
||||
/// Derived: whether there are more messages to load
|
||||
pub has_more: Memo<bool>,
|
||||
/// Last error message if any
|
||||
pub error: RwSignal<Option<String>>,
|
||||
/// Current guild ID
|
||||
pub current_guild: RwSignal<Option<String>>,
|
||||
/// Fetch initial messages for a guild
|
||||
pub fetch_messages: FetchMessagesCallback,
|
||||
/// Load next page of messages
|
||||
pub load_more: LoadMoreCallback,
|
||||
/// Reanalyze a single message
|
||||
pub reanalyze: ReanalyzeCallback,
|
||||
/// Reanalyze all error messages in current batch
|
||||
pub reanalyze_all_errors: ReanalyzeAllErrorsCallback,
|
||||
}
|
||||
|
||||
/// Hook to manage message data fetching and state
|
||||
pub fn use_messages() -> MessagesState {
|
||||
// Core signals
|
||||
let messages_signal = RwSignal::new(Vec::<MessageRecord>::new());
|
||||
let (loading, set_loading) = signal(false);
|
||||
let loading_more_signal = RwSignal::new(false);
|
||||
let cursor_signal = RwSignal::new(None::<String>);
|
||||
let error_signal = RwSignal::new(None::<String>);
|
||||
let current_guild_signal = RwSignal::new(None::<String>);
|
||||
|
||||
// Derived signal: has_more is true if cursor is Some
|
||||
let has_more_signal = Memo::new(move |_| cursor_signal.get().is_some());
|
||||
|
||||
// Fetch initial messages for a guild
|
||||
let fetch_messages_impl = Arc::new(move |guild_id: String| {
|
||||
spawn_local({
|
||||
let guild_id = guild_id.clone();
|
||||
async move {
|
||||
error_signal.set(None);
|
||||
set_loading.set(true);
|
||||
log_info!("Messages fetch start for guild {}", guild_id);
|
||||
|
||||
match get_messages(&guild_id, Some(30), None, None).await {
|
||||
Ok(PageResult { data, next_cursor }) => {
|
||||
log_info!("Messages fetch OK: count={}, cursor={:?}", data.len(), next_cursor);
|
||||
web_sys::console::log_3(
|
||||
&"[messages] fetch OK".into(),
|
||||
&format!("count={}", data.len()).into(),
|
||||
&format!("cursor={:?}", next_cursor).into(),
|
||||
);
|
||||
messages_signal.set(data);
|
||||
cursor_signal.set(next_cursor);
|
||||
current_guild_signal.set(Some(guild_id));
|
||||
set_loading.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
log_warn!("Messages fetch error: {}", e);
|
||||
web_sys::console::log_2(
|
||||
&"[messages] fetch ERROR".into(),
|
||||
&format!("{}", e).into(),
|
||||
);
|
||||
error_signal.set(Some(format!("Failed to fetch messages: {}", e)));
|
||||
set_loading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Load more messages (append next page)
|
||||
let load_more_impl = Arc::new(move || {
|
||||
spawn_local(async move {
|
||||
let guild_id = match current_guild_signal.get() {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
error_signal.set(Some("No guild selected".to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let cursor = match cursor_signal.get() {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
error_signal.set(Some("No more messages to load".to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
loading_more_signal.set(true);
|
||||
error_signal.set(None);
|
||||
log_info!("Messages load more for guild {}", guild_id);
|
||||
|
||||
match get_messages(&guild_id, Some(30), None, Some(&cursor)).await {
|
||||
Ok(PageResult { data, next_cursor }) => {
|
||||
log_info!("Messages load more OK: count={}, cursor={:?}", data.len(), next_cursor);
|
||||
let current = messages_signal.get();
|
||||
messages_signal.set(merge_messages(¤t, &data));
|
||||
cursor_signal.set(next_cursor);
|
||||
loading_more_signal.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
log_warn!("Messages load more error: {}", e);
|
||||
error_signal.set(Some(format!("Failed to load more: {}", e)));
|
||||
loading_more_signal.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Reanalyze single message with optimistic update
|
||||
let reanalyze_impl = Arc::new(move |message_id: String| {
|
||||
spawn_local({
|
||||
let message_id = message_id.clone();
|
||||
async move {
|
||||
log_info!("Messages reanalyze start for message {}", message_id);
|
||||
// Optimistic: flip status to Processing
|
||||
let mut msgs = messages_signal.get();
|
||||
if let Some(pos) = msgs.iter().position(|m| m.id == message_id) {
|
||||
if let Some(ref mut msg) = msgs.get_mut(pos) {
|
||||
msg.ai_status = Some(shared_types::message::AiStatus::Processing);
|
||||
}
|
||||
}
|
||||
messages_signal.set(msgs);
|
||||
|
||||
// Call API
|
||||
match reanalyze_message(&message_id).await {
|
||||
Ok(_) => {
|
||||
log_info!("Messages reanalyze OK for message {}", message_id);
|
||||
// Success: keep the Processing status (will be updated via WS)
|
||||
}
|
||||
Err(e) => {
|
||||
log_warn!("Messages reanalyze error for message {}: {}", message_id, e);
|
||||
// Revert to Error status on failure
|
||||
let mut msgs = messages_signal.get();
|
||||
if let Some(pos) = msgs.iter().position(|m| m.id == message_id) {
|
||||
if let Some(ref mut msg) = msgs.get_mut(pos) {
|
||||
msg.ai_status = Some(shared_types::message::AiStatus::Error);
|
||||
msg.ai_error = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
messages_signal.set(msgs);
|
||||
error_signal.set(Some(format!("Reanalyze failed: {}", e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Reanalyze all error messages
|
||||
let reanalyze_all_errors_impl = Arc::new(move || {
|
||||
spawn_local(async move {
|
||||
log_info!("Messages reanalyze all errors start");
|
||||
match reanalyze_batch().await {
|
||||
Ok(_count) => {
|
||||
log_info!("Messages reanalyze all errors OK: count={}", _count);
|
||||
error_signal.set(None);
|
||||
// Optimistically mark all error messages as Processing
|
||||
let mut msgs = messages_signal.get();
|
||||
for msg in msgs.iter_mut() {
|
||||
if msg.ai_status == Some(shared_types::message::AiStatus::Error) {
|
||||
msg.ai_status = Some(shared_types::message::AiStatus::Processing);
|
||||
}
|
||||
}
|
||||
messages_signal.set(msgs);
|
||||
}
|
||||
Err(e) => {
|
||||
log_info!("Messages reanalyze all errors failed: {}", e);
|
||||
error_signal.set(Some(format!("Batch reanalyze failed: {}", e)));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
MessagesState {
|
||||
messages: messages_signal,
|
||||
loading,
|
||||
loading_more: loading_more_signal,
|
||||
cursor: cursor_signal,
|
||||
has_more: has_more_signal,
|
||||
error: error_signal,
|
||||
current_guild: current_guild_signal,
|
||||
fetch_messages: fetch_messages_impl,
|
||||
load_more: load_more_impl,
|
||||
reanalyze: reanalyze_impl,
|
||||
reanalyze_all_errors: reanalyze_all_errors_impl,
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::message::MessageRecord;
|
||||
use std::sync::Arc;
|
||||
use super::components::message_feed::MessageFeed;
|
||||
use super::components::image_grid::ImageGrid;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum ViewTab {
|
||||
All,
|
||||
Images,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn MessageListView(
|
||||
view_tab: RwSignal<ViewTab>,
|
||||
show_search: ReadSignal<bool>,
|
||||
search_results: ReadSignal<Vec<MessageRecord>>,
|
||||
filtered_messages: Memo<Vec<MessageRecord>>,
|
||||
image_messages: ReadSignal<Vec<MessageRecord>>,
|
||||
loading: ReadSignal<bool>,
|
||||
has_more: Memo<bool>,
|
||||
loading_more: ReadSignal<bool>,
|
||||
on_load_more: Arc<dyn Fn() + Send + Sync + 'static>,
|
||||
on_reanalyze: Arc<dyn Fn(String) + Send + Sync + 'static>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
{/* Search results count */}
|
||||
{move || show_search.get().then(|| {
|
||||
let n = search_results.get().len();
|
||||
view! {
|
||||
<div class="search-count">
|
||||
"Found " {n} " result" {if n != 1 { "s" } else { "" }}
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
|
||||
{/* View tabs + content */}
|
||||
<div class="tabs">
|
||||
<div class="tab-list">
|
||||
<button
|
||||
class="tab-trigger"
|
||||
class:active=move || view_tab.get() == ViewTab::All
|
||||
on:click=move |_| view_tab.set(ViewTab::All)
|
||||
aria-selected=move || if view_tab.get() == ViewTab::All { "true" } else { "false" }
|
||||
>
|
||||
{move || {
|
||||
let label = if show_search.get() { "Search" } else { "All" };
|
||||
format!("{} ({})", label, filtered_messages.with(|m| m.len()))
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
class="tab-trigger"
|
||||
class:active=move || view_tab.get() == ViewTab::Images
|
||||
on:click=move |_| view_tab.set(ViewTab::Images)
|
||||
aria-selected=move || if view_tab.get() == ViewTab::Images { "true" } else { "false" }
|
||||
>
|
||||
"Images"
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" style:display=move || if view_tab.get() == ViewTab::All { "block" } else { "none" }>
|
||||
{move || {
|
||||
let effective_has_more = if show_search.get() { false } else { has_more.get() };
|
||||
let load_more_cb = on_load_more.clone();
|
||||
let empty_text: &'static str = if show_search.get() { "No messages found matching your search." } else { "No captures yet." };
|
||||
let on_load_more_clone: Arc<dyn Fn() + Send + Sync + 'static> = Arc::new(move || load_more_cb());
|
||||
view! {
|
||||
<MessageFeed
|
||||
messages=filtered_messages.get()
|
||||
empty_text=empty_text
|
||||
loading=loading.get()
|
||||
has_more=effective_has_more
|
||||
loading_more=loading_more.get()
|
||||
on_load_more=on_load_more_clone
|
||||
on_reanalyze=on_reanalyze.clone()
|
||||
/>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
<div class="tab-content" style:display=move || if view_tab.get() == ViewTab::Images { "block" } else { "none" }>
|
||||
{move || view! {
|
||||
<ImageGrid messages=image_messages.get() />
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
use shared_types::message::{AiStatus, MessageRecord, PageResult};
|
||||
use std::sync::Arc;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use crate::{log_debug, log_error, log_info, log_trace, log_warn, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
/// Threads whose messages should be hidden from both the feed and the
|
||||
/// Images tab. A bot or selfbot may be spamming in a thread, polluting
|
||||
/// the dashboard — add its thread ID here to keep the view clean.
|
||||
const EXCLUDED_THREAD_IDS: &[&str] = &["1522077685508083893"];
|
||||
|
||||
fn is_excluded_thread(m: &MessageRecord) -> bool {
|
||||
m.thread_id
|
||||
.as_deref()
|
||||
.is_some_and(|tid| EXCLUDED_THREAD_IDS.contains(&tid))
|
||||
}
|
||||
|
||||
pub mod components;
|
||||
pub mod hooks;
|
||||
|
||||
use components::image_grid::ImageGrid;
|
||||
use components::message_feed::MessageFeed;
|
||||
use hooks::use_messages::{merge_messages, use_messages};
|
||||
|
||||
type AiFilter = &'static str;
|
||||
const FILTERS: &[AiFilter] = &["all", "analyzed", "clean", "flagged", "error", "pending"];
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum ViewTab {
|
||||
All,
|
||||
Images,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn MessagesPanel() -> impl IntoView {
|
||||
let state = use_messages();
|
||||
let (search_query, set_search_query) = signal(String::new());
|
||||
let (search_results, set_search_results) = signal::<Vec<MessageRecord>>(Vec::new());
|
||||
let (show_search, set_show_search) = signal(false);
|
||||
let (is_searching, set_is_searching) = signal(false);
|
||||
let ai_filter = RwSignal::new("analyzed".to_string());
|
||||
let view_tab = RwSignal::new(ViewTab::All);
|
||||
let image_messages = RwSignal::new(Vec::<MessageRecord>::new());
|
||||
let (retrying_all, set_retrying_all) = signal(false);
|
||||
|
||||
// Stats derived from filtered messages
|
||||
let stats = Memo::new(move |_| {
|
||||
let base = if show_search.get() {
|
||||
search_results.get()
|
||||
} else {
|
||||
state.messages.get()
|
||||
};
|
||||
let total = base.len();
|
||||
let clean = base
|
||||
.iter()
|
||||
.filter(|m| m.ai_status == Some(AiStatus::Clean))
|
||||
.count();
|
||||
let flagged = base
|
||||
.iter()
|
||||
.filter(|m| m.ai_status == Some(AiStatus::Flagged))
|
||||
.count();
|
||||
let error = base
|
||||
.iter()
|
||||
.filter(|m| m.ai_status == Some(AiStatus::Error))
|
||||
.count();
|
||||
let pending = base
|
||||
.iter()
|
||||
.filter(|m| m.ai_status.is_none() || m.ai_status == Some(AiStatus::Pending))
|
||||
.count();
|
||||
let deleted = base.iter().filter(|m| m.deleted_at.is_some()).count();
|
||||
let edited = base.iter().filter(|m| m.edited_at.is_some()).count();
|
||||
(total, clean, flagged, error, pending, deleted, edited)
|
||||
});
|
||||
|
||||
// Filter messages based on active filter
|
||||
let filtered_messages = Memo::new(move |_| {
|
||||
let base = if show_search.get() {
|
||||
search_results.get()
|
||||
} else {
|
||||
state.messages.get()
|
||||
};
|
||||
let filter = ai_filter.get();
|
||||
if filter == "all" {
|
||||
return base;
|
||||
}
|
||||
base.into_iter()
|
||||
.filter(|m| {
|
||||
let status = m.ai_status.clone().unwrap_or(AiStatus::Pending);
|
||||
if filter == "analyzed" {
|
||||
return status != AiStatus::Pending;
|
||||
}
|
||||
if filter == "pending" {
|
||||
return status == AiStatus::Pending;
|
||||
}
|
||||
format!("{:?}", status).to_lowercase() == filter
|
||||
})
|
||||
.filter(|m| !is_excluded_thread(m))
|
||||
.collect()
|
||||
});
|
||||
|
||||
// Search handler - takes any event type and triggers the search
|
||||
let do_search = {
|
||||
let q = search_query;
|
||||
move || {
|
||||
let query = q.get();
|
||||
if query.trim().is_empty() {
|
||||
set_show_search.set(false);
|
||||
set_search_results.set(Vec::new());
|
||||
return;
|
||||
}
|
||||
set_is_searching.set(true);
|
||||
let q_clone = query.trim().to_string();
|
||||
log_info!("Messages searching for: {}", q_clone);
|
||||
spawn_local(async move {
|
||||
match crate::api::messages::search_messages(&q_clone, Some(50)).await {
|
||||
Ok(results) => {
|
||||
log_info!("Messages search found {} results", results.len());
|
||||
set_search_results.set(results);
|
||||
set_show_search.set(true);
|
||||
}
|
||||
Err(_) => {
|
||||
log_warn!("Messages search failed");
|
||||
set_search_results.set(Vec::new());
|
||||
}
|
||||
}
|
||||
set_is_searching.set(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
// Separate closures for different event types so on:click/on:keydown type-check
|
||||
let handle_search_click = move |_: web_sys::MouseEvent| do_search();
|
||||
let handle_search_keydown = move |_: web_sys::KeyboardEvent| do_search();
|
||||
|
||||
// Clear search
|
||||
let clear_search = move |_| {
|
||||
set_show_search.set(false);
|
||||
set_search_results.set(Vec::new());
|
||||
set_search_query.set(String::new());
|
||||
};
|
||||
|
||||
// Filter chip click
|
||||
let set_filter = {
|
||||
let af = ai_filter;
|
||||
move |f: &'static str| af.set(f.to_string())
|
||||
};
|
||||
|
||||
// WS event handlers (wire once on mount)
|
||||
let ws = use_context::<crate::ws::context::WsContext>();
|
||||
if let Some(ref ws) = ws {
|
||||
log_info!("MessagesPanel wiring WS handlers");
|
||||
// Subscribe to real-time message events
|
||||
{
|
||||
let msgs = state.messages;
|
||||
*ws.on_message_created.borrow_mut() = Some(Box::new(move |msg| {
|
||||
log_debug!("WS message_created received: {}", msg.id);
|
||||
let current = msgs.get();
|
||||
msgs.set(merge_messages(¤t, &[msg]));
|
||||
}));
|
||||
}
|
||||
{
|
||||
let msgs = state.messages;
|
||||
*ws.on_message_updated.borrow_mut() = Some(Box::new(move |msg| {
|
||||
log_debug!("WS message_updated received: {}", msg.id);
|
||||
let current = msgs.get();
|
||||
msgs.set(merge_messages(¤t, &[msg]));
|
||||
}));
|
||||
}
|
||||
{
|
||||
let msgs = state.messages;
|
||||
*ws.on_message_deleted.borrow_mut() = Some(Box::new(move |id| {
|
||||
log_debug!("WS message_deleted received: {}", id);
|
||||
let current = msgs.get();
|
||||
msgs.set(current.into_iter().filter(|m| m.id != id).collect());
|
||||
}));
|
||||
}
|
||||
{
|
||||
let msgs = state.messages;
|
||||
*ws.on_message_analyzed.borrow_mut() = Some(Box::new(move |msg| {
|
||||
log_debug!("WS message_analyzed received: {}", msg.id);
|
||||
let current = msgs.get();
|
||||
msgs.set(merge_messages(¤t, &[msg]));
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch messages on mount if guild is configured
|
||||
Effect::new(move |_| {
|
||||
if let Some(config) = use_context::<crate::app::AppConfig>() {
|
||||
if let Some(ref guild_id) = config.monitor_guild_id.get() {
|
||||
let gid = guild_id.clone();
|
||||
(state.fetch_messages)(gid);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch image messages when Images tab is selected
|
||||
let fetch_images = {
|
||||
let image_messages = image_messages.clone();
|
||||
move || {
|
||||
let guild_id = use_context::<crate::app::AppConfig>()
|
||||
.and_then(|c| c.monitor_guild_id.get());
|
||||
if let Some(gid) = guild_id {
|
||||
log_info!("Messages fetching images for guild {}", gid);
|
||||
spawn_local({
|
||||
let image_messages = image_messages.clone();
|
||||
async move {
|
||||
match crate::api::messages::get_images(&gid, Some(100)).await {
|
||||
Ok(PageResult { data, .. }) => {
|
||||
log_info!("Messages images loaded: count={}", data.len());
|
||||
image_messages.set(
|
||||
data.into_iter()
|
||||
.filter(|m| !is_excluded_thread(m))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log_error!("Messages images error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
// Fetch images when tab changes to Images
|
||||
Effect::new(move |_| {
|
||||
if view_tab.get() == ViewTab::Images {
|
||||
fetch_images();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── View ────────────────────────────────────────────────
|
||||
let get_stats = move || stats.get();
|
||||
let (total, clean, flagged, error, pending, deleted, edited) = (
|
||||
move || get_stats().0,
|
||||
move || get_stats().1,
|
||||
move || get_stats().2,
|
||||
move || get_stats().3,
|
||||
move || get_stats().4,
|
||||
move || get_stats().5,
|
||||
move || get_stats().6,
|
||||
);
|
||||
|
||||
view! {
|
||||
<div class="messages-panel">
|
||||
{/* Header card */}
|
||||
<div class="panel-card">
|
||||
<div class="panel-card-head">
|
||||
<div class="panel-card-title">"Messages"</div>
|
||||
<p class="panel-card-desc">
|
||||
"Messages are automatically captured from all text channels. Real-time updates arrive via WebSocket."
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats badges */}
|
||||
{move || (total() > 0).then(|| view! {
|
||||
<div class="stats-bar">
|
||||
<span class="badge badge-outline text-xs">{total()} " total" {state.has_more.get().then_some("+")}</span>
|
||||
<span class="badge badge-success text-xs">{clean()} " clean"</span>
|
||||
<span class="badge badge-primary text-xs">{flagged()} " flagged"</span>
|
||||
<span class="badge badge-warning text-xs">{error()} " error"</span>
|
||||
<span class="badge badge-outline text-xs">{pending()} " pending"</span>
|
||||
{(deleted() > 0).then(|| view! {
|
||||
<span class="badge badge-destructive text-xs">{deleted()} " deleted"</span>
|
||||
})}
|
||||
{(edited() > 0).then(|| view! {
|
||||
<span class="badge badge-outline text-xs">{edited()} " edited"</span>
|
||||
})}
|
||||
</div>
|
||||
})}
|
||||
|
||||
{/* Search + filters row */}
|
||||
<div class="search-bar">
|
||||
<div class="search-wrap">
|
||||
<svg width="16" height="16" class="search-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.35-4.35"></path></svg>
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder="Search message content..."
|
||||
prop:value=search_query
|
||||
on:input=move |ev| set_search_query.set(event_target_value(&ev))
|
||||
on:keydown=move |ev| {
|
||||
if ev.key() == "Enter" { handle_search_keydown(ev); }
|
||||
}
|
||||
disabled=move || is_searching.get()
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
on:click=handle_search_click
|
||||
disabled=move || is_searching.get() || search_query.get().trim().is_empty()
|
||||
>
|
||||
{move || if is_searching.get() { "Searching..." } else { "Search" }}
|
||||
</button>
|
||||
{move || show_search.get().then(|| view! {
|
||||
<button class="btn btn-outline btn-sm" on:click=clear_search>
|
||||
"✕ Clear"
|
||||
</button>
|
||||
})}
|
||||
{move || {
|
||||
(error() > 0 && !show_search.get()).then(|| {
|
||||
let cb = state.reanalyze_all_errors.clone();
|
||||
let err_count = error();
|
||||
view! {
|
||||
<button
|
||||
class="btn btn-destructive btn-sm"
|
||||
on:click=move |_| {
|
||||
set_retrying_all.set(true);
|
||||
let cb = cb.clone();
|
||||
spawn_local(async move {
|
||||
cb();
|
||||
set_retrying_all.set(false);
|
||||
});
|
||||
}
|
||||
disabled=move || retrying_all.get()
|
||||
>
|
||||
<svg class=format!("mr-1.5 h-3.5 w-3.5{}", if retrying_all.get() { " icon-spin" } else { "" }) xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"></path><path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"></path></svg>
|
||||
{move || if retrying_all.get() { "Retrying...".to_string() } else { format!("Retry All Errors ({})", err_count) }}
|
||||
</button>
|
||||
}
|
||||
})
|
||||
}}
|
||||
<div class="filter-group">
|
||||
<svg width="16" height="16" style="color:var(--color-primary)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg>
|
||||
{FILTERS.iter().map(|f| {
|
||||
let f_ptr: &'static str = f;
|
||||
view! {
|
||||
<button class="filter-chip-v2" class:is-active=move || ai_filter.get() == f_ptr on:click=move |_| set_filter(f_ptr) >{*f}</button>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search results count */}
|
||||
{move || show_search.get().then(|| {
|
||||
let n = search_results.get().len();
|
||||
view! {
|
||||
<div class="search-count">
|
||||
"Found " {n} " result" {if n != 1 { "s" } else { "" }}
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
|
||||
{/* View tabs + content */}
|
||||
<div class="tabs">
|
||||
<div class="tab-list">
|
||||
<button
|
||||
class="tab-trigger"
|
||||
class:active=move || view_tab.get() == ViewTab::All
|
||||
on:click=move |_| view_tab.set(ViewTab::All)
|
||||
aria-selected=move || if view_tab.get() == ViewTab::All { "true" } else { "false" }
|
||||
>
|
||||
{move || {
|
||||
let label = if show_search.get() { "Search" } else { "All" };
|
||||
format!("{} ({})", label, filtered_messages.with(|m| m.len()))
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
class="tab-trigger"
|
||||
class:active=move || view_tab.get() == ViewTab::Images
|
||||
on:click=move |_| view_tab.set(ViewTab::Images)
|
||||
aria-selected=move || if view_tab.get() == ViewTab::Images { "true" } else { "false" }
|
||||
>
|
||||
"Images"
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" style:display=move || if view_tab.get() == ViewTab::All { "block" } else { "none" }>
|
||||
{move || {
|
||||
let load_more_cb = state.load_more.clone();
|
||||
let empty_text: &'static str = if show_search.get() { "No messages found matching your search." } else { "No captures yet." };
|
||||
let has_more = if show_search.get() { false } else { state.has_more.get() };
|
||||
let on_load_more_clone: Arc<dyn Fn() + Send + Sync + 'static> = Arc::new(move || load_more_cb());
|
||||
view! {
|
||||
<MessageFeed
|
||||
messages=filtered_messages.get()
|
||||
empty_text=empty_text
|
||||
loading=state.loading.get()
|
||||
has_more=has_more
|
||||
loading_more=state.loading_more.get()
|
||||
on_load_more=on_load_more_clone
|
||||
on_reanalyze=state.reanalyze.clone()
|
||||
/>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
<div class="tab-content" style:display=move || if view_tab.get() == ViewTab::Images { "block" } else { "none" }>
|
||||
{move || view! {
|
||||
<ImageGrid messages=image_messages.get() />
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
pub mod dashboard;
|
||||
pub mod live;
|
||||
pub mod messages;
|
||||
pub mod polish;
|
||||
@@ -1,191 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum ChatRole {
|
||||
User,
|
||||
Mascot,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ChatMessage {
|
||||
#[allow(dead_code)]
|
||||
id: String,
|
||||
role: ChatRole,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn MascotChatbot() -> impl IntoView {
|
||||
let open = RwSignal::new(false);
|
||||
let minimized = RwSignal::new(false);
|
||||
let loading = RwSignal::new(false);
|
||||
let input = RwSignal::new(String::new());
|
||||
let messages = RwSignal::new(vec![ChatMessage {
|
||||
id: "init-1".to_string(),
|
||||
role: ChatRole::Mascot,
|
||||
content:
|
||||
"Halo! 👋 Aku mascot IMPHNEN. Tanya aku soal analytics, pesan, atau moderation queue."
|
||||
.to_string(),
|
||||
}]);
|
||||
|
||||
// Fetch chat history when the panel opens
|
||||
Effect::new(move |_| {
|
||||
if open.get() {
|
||||
spawn_local(async move {
|
||||
if let Ok(history) = crate::api::mascot::get_chat_history().await {
|
||||
messages.update(|list| {
|
||||
// Keep the initial greeting, then append history messages
|
||||
let greeting = list.first().cloned();
|
||||
list.clear();
|
||||
if let Some(g) = greeting {
|
||||
list.push(g);
|
||||
}
|
||||
for msg in history {
|
||||
let role = if msg.role == "user" {
|
||||
ChatRole::User
|
||||
} else {
|
||||
ChatRole::Mascot
|
||||
};
|
||||
list.push(ChatMessage {
|
||||
id: format!("hist-{}", list.len()),
|
||||
role,
|
||||
content: msg.content,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let send_message = move || {
|
||||
let text = input.get_untracked().trim().to_string();
|
||||
if text.is_empty() || loading.get_untracked() {
|
||||
return;
|
||||
}
|
||||
|
||||
let now = js_sys::Date::now() as u64;
|
||||
messages.update(|list| {
|
||||
list.push(ChatMessage {
|
||||
id: format!("user-{}", now),
|
||||
role: ChatRole::User,
|
||||
content: text.clone(),
|
||||
})
|
||||
});
|
||||
input.set(String::new());
|
||||
loading.set(true);
|
||||
|
||||
spawn_local(async move {
|
||||
let response = match crate::api::mascot::send_mascot_message(&text).await {
|
||||
Ok(resp) => resp.response,
|
||||
Err(_) => fallback_response(&text),
|
||||
};
|
||||
|
||||
messages.update(|list| {
|
||||
list.push(ChatMessage {
|
||||
id: format!("mascot-{}", js_sys::Date::now() as u64),
|
||||
role: ChatRole::Mascot,
|
||||
content: response,
|
||||
})
|
||||
});
|
||||
loading.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="mascot-widget">
|
||||
{move || if open.get() {
|
||||
view! {
|
||||
<div class=move || if minimized.get() { "mascot-panel minimized" } else { "mascot-panel" }>
|
||||
<div class="mascot-header">
|
||||
<div class="mascot-controls">
|
||||
<div class="mascot-header-icon">"💬"</div>
|
||||
<div>
|
||||
<div class="mascot-title">"Mascot IMPHNEN"</div>
|
||||
<div class="mascot-subtitle">{move || if loading.get() { "Mengetik..." } else { "Online" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mascot-inline">
|
||||
<button class="mascot-icon-button" on:click=move |_| minimized.update(|v| *v = !*v)>
|
||||
{move || if minimized.get() { "▣" } else { "—" }}
|
||||
</button>
|
||||
<button class="mascot-icon-button" on:click=move |_| open.set(false)>"×"</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{move || (!minimized.get()).then(|| view! {
|
||||
<>
|
||||
<div class="mascot-messages">
|
||||
{messages.get().into_iter().map(|msg| {
|
||||
let is_user = msg.role == ChatRole::User;
|
||||
view! {
|
||||
<div class=if is_user { "mascot-message-row user" } else { "mascot-message-row mascot" }>
|
||||
{(!is_user).then(|| view! { <div class="mascot-avatar">"🤖"</div> })}
|
||||
<div class=if is_user { "mascot-bubble user" } else { "mascot-bubble mascot" }>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
|
||||
{loading.get().then(|| view! {
|
||||
<div class="mascot-message-row mascot">
|
||||
<div class="mascot-avatar">"🤖"</div>
|
||||
<div class="mascot-bubble mascot typing">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
|
||||
<form class="mascot-form" on:submit=move |ev| {
|
||||
ev.prevent_default();
|
||||
send_message();
|
||||
}>
|
||||
<input
|
||||
class="mascot-input"
|
||||
placeholder="Tanya mascot..."
|
||||
prop:value=input
|
||||
on:input=move |ev| input.set(event_target_value(&ev))
|
||||
disabled=move || loading.get()
|
||||
/>
|
||||
<button
|
||||
class="mascot-send"
|
||||
type="submit"
|
||||
disabled=move || loading.get() || input.get().trim().is_empty()
|
||||
>
|
||||
"➤"
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
})}
|
||||
</div>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<button class="mascot-launcher" on:click=move |_| open.set(true) title="Open mascot chat">
|
||||
<span>"🤖"</span>
|
||||
</button>
|
||||
}.into_any()
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_response(input: &str) -> String {
|
||||
let lower = input.to_lowercase();
|
||||
if lower.contains("halo") || lower.contains("hai") {
|
||||
"Halo juga! 👋 Aku siap bantu baca kondisi server.".to_string()
|
||||
} else if lower.contains("pesan") || lower.contains("message") {
|
||||
"Cek tab Messages untuk live capture dan hasil AI moderation terbaru.".to_string()
|
||||
} else if lower.contains("voice") || lower.contains("audio") {
|
||||
"Tab Voice & Media punya voice bridge, speakers, media controls, dan recordings."
|
||||
.to_string()
|
||||
} else if lower.contains("dashboard") || lower.contains("stat") {
|
||||
"Dashboard Guild merangkum total pesan, user aktif, channel teratas, dan moderation queue."
|
||||
.to_string()
|
||||
} else {
|
||||
format!("Menarik: \"{}\". Kalau backend mascot offline, aku tetap bisa bantu arahkan ke Messages, Voice, atau Dashboard. 😊", input)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
pub mod mascot_chatbot;
|
||||
pub mod particle_background;
|
||||
pub mod theme_toggle;
|
||||
|
||||
pub use mascot_chatbot::MascotChatbot;
|
||||
pub use particle_background::ParticleBackground;
|
||||
pub use theme_toggle::ThemeToggle;
|
||||
@@ -1,12 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn ParticleBackground() -> impl IntoView {
|
||||
view! {
|
||||
<div class="particle-bg" aria-hidden="true">
|
||||
<div class="particle-orb"></div>
|
||||
<div class="particle-orb"></div>
|
||||
<div class="particle-orb"></div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
use crate::features::polish::{persist_theme, ThemeContext};
|
||||
use leptos::prelude::*;
|
||||
use crate::{log_info, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
#[component]
|
||||
pub fn ThemeToggle() -> impl IntoView {
|
||||
let theme_ctx = use_context::<ThemeContext>();
|
||||
let theme_for_label = theme_ctx.clone();
|
||||
let theme_for_toggle = theme_ctx.clone();
|
||||
|
||||
let is_dark = move || {
|
||||
theme_for_label
|
||||
.as_ref()
|
||||
.map(|ctx| ctx.theme.get() == "dark")
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
let toggle = move |_| {
|
||||
if let Some(ctx) = theme_for_toggle.as_ref() {
|
||||
let next = if ctx.theme.get() == "dark" {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
};
|
||||
log_info!("Theme toggled to {}", next);
|
||||
ctx.theme.set(next.to_string());
|
||||
persist_theme(next);
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<button
|
||||
class="theme-toggle"
|
||||
on:click=toggle
|
||||
aria-label="Toggle theme"
|
||||
title="Toggle theme"
|
||||
>
|
||||
{move || if is_dark() { "☀" } else { "☾" }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
pub mod components;
|
||||
|
||||
use leptos::prelude::*;
|
||||
use crate::{log_info, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ThemeContext {
|
||||
pub theme: RwSignal<String>,
|
||||
}
|
||||
|
||||
pub fn initial_theme() -> String {
|
||||
let theme = web_sys::window()
|
||||
.and_then(|window| window.local_storage().ok().flatten())
|
||||
.and_then(|storage| storage.get_item("imphnen-theme").ok().flatten())
|
||||
.filter(|value| value == "dark" || value == "light")
|
||||
.unwrap_or_else(|| "dark".to_string());
|
||||
log_info!("Initial theme resolved: {}", theme);
|
||||
theme
|
||||
}
|
||||
|
||||
pub fn persist_theme(theme: &str) {
|
||||
log_info!("Persisting theme: {}", theme);
|
||||
if let Some(storage) =
|
||||
web_sys::window().and_then(|window| window.local_storage().ok().flatten())
|
||||
{
|
||||
let _ = storage.set_item("imphnen-theme", theme);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
// services/frontend-leptos/frontend/src/layout/dashboard_layout.rs
|
||||
use super::mobile_tab_bar::MobileTabBar;
|
||||
use super::sidebar::Sidebar;
|
||||
use leptos::children::Children;
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn DashboardLayout(children: Children) -> impl IntoView {
|
||||
view! {
|
||||
<div class="app-shell">
|
||||
<Sidebar />
|
||||
<main class="app-content" style="padding: 0; max-width: none;">
|
||||
{children()}
|
||||
</main>
|
||||
<MobileTabBar />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// services/frontend-leptos/frontend/src/layout/header.rs
|
||||
use crate::ws::context::WsContext;
|
||||
use crate::ws::handlers::WsStatus;
|
||||
use leptos::prelude::*;
|
||||
|
||||
|
||||
#[component]
|
||||
pub fn Header() -> impl IntoView {
|
||||
let ws = use_context::<WsContext>().expect("WsContext not provided");
|
||||
let ws_status = ws.status;
|
||||
|
||||
let indicator_text_memo = Memo::new(move |_| match ws_status.get() {
|
||||
WsStatus::Connected => "Online",
|
||||
WsStatus::Connecting => "Menghubungkan...",
|
||||
WsStatus::Disconnected => "Offline",
|
||||
WsStatus::Error(_) => "Error",
|
||||
});
|
||||
let indicator_color_memo = Memo::new(move |_| match ws_status.get() {
|
||||
WsStatus::Connected => "var(--color-success)",
|
||||
WsStatus::Connecting => "var(--color-warning)",
|
||||
WsStatus::Disconnected => "var(--text-tertiary)",
|
||||
WsStatus::Error(_) => "var(--color-error)",
|
||||
});
|
||||
let is_connecting = Memo::new(move |_| matches!(ws_status.get(), WsStatus::Connecting));
|
||||
|
||||
view! {
|
||||
<header style="
|
||||
height: var(--header-height);
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1.5rem;
|
||||
background: var(--surface-base);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-header);
|
||||
">
|
||||
<div class="head-left">
|
||||
<span style="font-weight: 700; font-size: 1.125rem; color: var(--color-primary);">
|
||||
"IMPHNEN"
|
||||
</span>
|
||||
<span style="color: var(--text-secondary); font-size: 0.75rem; padding: 0.125rem 0.5rem; background: var(--surface-overlay); border-radius: var(--radius-full);">
|
||||
"Guild Watcher"
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="head-right">
|
||||
<div class="head-status">
|
||||
<span
|
||||
class="head-status-dot"
|
||||
class:is-connecting=is_connecting
|
||||
style:background={move || indicator_color_memo.get()}
|
||||
></span>
|
||||
<span>{move || indicator_text_memo.get()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
use crate::app::UiContext;
|
||||
use leptos::prelude::*;
|
||||
use shared_types::ui_state::Tab;
|
||||
|
||||
#[component]
|
||||
pub fn MobileTabBar() -> impl IntoView {
|
||||
let ui = use_context::<UiContext>().expect("UiContext not provided");
|
||||
|
||||
view! {
|
||||
<div class="mobile-tab-bar">
|
||||
<MobileTabItem label="Pesan" tab=Tab::Messages ui=ui.clone() />
|
||||
<MobileTabItem label="Voice" tab=Tab::Live ui=ui.clone() />
|
||||
<MobileTabItem label="Dashboard" tab=Tab::Dashboard ui=ui.clone() />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn MobileTabItem(label: &'static str, tab: Tab, ui: UiContext) -> impl IntoView {
|
||||
let tab_active = tab.clone();
|
||||
let tab_click = tab;
|
||||
|
||||
view! {
|
||||
<button
|
||||
class="mobile-tab-item"
|
||||
class:is-active=move || ui.active_tab.get() == tab_active
|
||||
on:click=move |_| ui.active_tab.set(tab_click.clone())
|
||||
>
|
||||
<span class="mobile-tab-item-icon">
|
||||
{match tab_active {
|
||||
Tab::Messages => "💬",
|
||||
Tab::Live => "🎮",
|
||||
Tab::Dashboard => "📊",
|
||||
}}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
// services/frontend-leptos/frontend/src/layout/mod.rs
|
||||
pub mod dashboard_layout;
|
||||
pub mod mobile_tab_bar;
|
||||
pub mod sidebar;
|
||||
pub mod tab_strip;
|
||||
@@ -1,118 +0,0 @@
|
||||
use crate::app::UiContext;
|
||||
use crate::features::polish::{persist_theme, ThemeContext};
|
||||
use crate::ws::context::WsContext;
|
||||
use crate::ws::handlers::WsStatus;
|
||||
use leptos::prelude::*;
|
||||
use shared_types::ui_state::Tab;
|
||||
|
||||
#[component]
|
||||
pub fn Sidebar() -> impl IntoView {
|
||||
let ui = use_context::<UiContext>().expect("UiContext not provided");
|
||||
let ws = use_context::<WsContext>();
|
||||
let theme_ctx = use_context::<ThemeContext>();
|
||||
|
||||
// WS status
|
||||
let ws_status = ws.as_ref().map(|w| w.status);
|
||||
let status_text = Memo::new(move |_| match ws_status.map(|s| s.get()) {
|
||||
Some(WsStatus::Connected) => "Online",
|
||||
Some(WsStatus::Connecting) => "Menghubungkan...",
|
||||
Some(WsStatus::Disconnected) => "Offline",
|
||||
Some(WsStatus::Error(_)) => "Error",
|
||||
None => "Offline",
|
||||
});
|
||||
let status_color = Memo::new(move |_| match ws_status.map(|s| s.get()) {
|
||||
Some(WsStatus::Connected) => "var(--color-success)",
|
||||
Some(WsStatus::Connecting) => "var(--color-warning)",
|
||||
Some(WsStatus::Disconnected) => "var(--text-tertiary)",
|
||||
Some(WsStatus::Error(_)) => "var(--color-error)",
|
||||
None => "var(--text-tertiary)",
|
||||
});
|
||||
let is_connecting = Memo::new(move |_| matches!(ws_status.map(|s| s.get()), Some(WsStatus::Connecting)));
|
||||
|
||||
// Theme toggle
|
||||
let theme_ctx_for_dark = theme_ctx.clone();
|
||||
let is_dark = move || {
|
||||
theme_ctx_for_dark
|
||||
.as_ref()
|
||||
.map(|ctx| ctx.theme.get() == "dark")
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let toggle_theme = move |_| {
|
||||
if let Some(ctx) = theme_ctx.as_ref() {
|
||||
let next = if ctx.theme.get() == "dark" { "light" } else { "dark" };
|
||||
ctx.theme.set(next.to_string());
|
||||
persist_theme(next);
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<nav class="app-sidebar">
|
||||
{/* Brand */}
|
||||
<div class="sidebar-brand">
|
||||
<span class="sidebar-brand-icon">"◉"</span>
|
||||
<div class="sidebar-brand-text">
|
||||
<span class="sidebar-brand-name">"IMPHNEN"</span>
|
||||
<span class="sidebar-brand-subtitle">"Guild Watcher"</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div class="sidebar-nav">
|
||||
<SidebarNavItem
|
||||
icon="💬"
|
||||
label="Pesan & Moderasi"
|
||||
tab=Tab::Messages
|
||||
ui=ui.clone()
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon="🎮"
|
||||
label="Voice & Media"
|
||||
tab=Tab::Live
|
||||
ui=ui.clone()
|
||||
/>
|
||||
<SidebarNavItem
|
||||
icon="📊"
|
||||
label="Dashboard Guild"
|
||||
tab=Tab::Dashboard
|
||||
ui=ui.clone()
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer with WS status + Theme Toggle */}
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-footer-status">
|
||||
<span
|
||||
class="status-dot"
|
||||
class:is-connecting=is_connecting
|
||||
style:background=status_color
|
||||
></span>
|
||||
<span class="status-text">{move || status_text.get()}</span>
|
||||
</div>
|
||||
<button
|
||||
class="sidebar-theme-btn"
|
||||
on:click=toggle_theme
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{move || if is_dark() { "☀" } else { "☾" }}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn SidebarNavItem(icon: &'static str, label: &'static str, tab: Tab, ui: UiContext) -> impl IntoView {
|
||||
let tab_for_active = tab.clone();
|
||||
let tab_for_click = tab;
|
||||
|
||||
view! {
|
||||
<button
|
||||
class="sidebar-nav-item"
|
||||
class:is-active=move || ui.active_tab.get() == tab_for_active
|
||||
on:click=move |_| ui.active_tab.set(tab_for_click.clone())
|
||||
>
|
||||
<span class="sidebar-nav-icon">{icon}</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// services/frontend-leptos/frontend/src/layout/tab_strip.rs
|
||||
use crate::app::UiContext;
|
||||
use leptos::prelude::*;
|
||||
use shared_types::ui_state::Tab;
|
||||
|
||||
|
||||
#[component]
|
||||
pub fn TabStrip() -> impl IntoView {
|
||||
let ui = use_context::<UiContext>().expect("UiContext not provided");
|
||||
|
||||
view! {
|
||||
<div style="
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
padding: 0 1rem;
|
||||
background: var(--surface-base);
|
||||
">
|
||||
<TabItem label="Pesan & Moderasi" tab=Tab::Messages ui=ui.clone() />
|
||||
<TabItem label="Voice & Media" tab=Tab::Live ui=ui.clone() />
|
||||
<TabItem label="Dashboard Guild" tab=Tab::Dashboard ui=ui.clone() />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn TabItem(label: &'static str, tab: Tab, ui: UiContext) -> impl IntoView {
|
||||
let tab_color = tab.clone();
|
||||
let tab_border = tab.clone();
|
||||
let tab_click = tab;
|
||||
view! {
|
||||
<button
|
||||
on:click=move |_| ui.active_tab.set(tab_click.clone())
|
||||
style="
|
||||
padding: 0.75rem 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: all var(--transition-fast);
|
||||
"
|
||||
style:color=move || if ui.active_tab.get() == tab_color { "var(--color-primary)" } else { "" }
|
||||
style:border-bottom-color=move || if ui.active_tab.get() == tab_border { "var(--color-primary)" } else { "transparent" }
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
pub mod api;
|
||||
pub mod app;
|
||||
pub mod auth;
|
||||
pub mod features;
|
||||
pub mod layout;
|
||||
pub mod logger;
|
||||
pub mod ui;
|
||||
pub mod ws;
|
||||
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
make_logger!();
|
||||
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn start() {
|
||||
console_error_panic_hook::set_once();
|
||||
wasm_logger::init(wasm_logger::Config::default());
|
||||
log_info!("IMPHNEN frontend starting...");
|
||||
leptos::mount::mount_to_body(app::App);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
// services/frontend/frontend/src/logger.rs
|
||||
// Structured logging for WASM browser console with levels, timestamps, and styled output.
|
||||
|
||||
/// Log level with numeric priority (lower = more verbose).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum LogLevel {
|
||||
Trace = 0,
|
||||
Debug = 1,
|
||||
Info = 2,
|
||||
Warn = 3,
|
||||
Error = 4,
|
||||
}
|
||||
|
||||
impl LogLevel {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
LogLevel::Trace => "TRACE",
|
||||
LogLevel::Debug => "DEBUG",
|
||||
LogLevel::Info => "INFO",
|
||||
LogLevel::Warn => "WARN",
|
||||
LogLevel::Error => "ERROR",
|
||||
}
|
||||
}
|
||||
|
||||
/// CSS color for the browser console label.
|
||||
fn console_style(&self) -> &'static str {
|
||||
match self {
|
||||
LogLevel::Trace => "color:#888",
|
||||
LogLevel::Debug => "color:#54a2ff",
|
||||
LogLevel::Info => "color:#23a1eb;font-weight:bold",
|
||||
LogLevel::Warn => "color:#f59e0b;font-weight:bold",
|
||||
LogLevel::Error => "color:#e4405f;font-weight:bold",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A per-module logger that produces styled, timestamped console output.
|
||||
#[derive(Clone)]
|
||||
pub struct Logger {
|
||||
module: &'static str,
|
||||
min_level: LogLevel,
|
||||
}
|
||||
|
||||
impl Logger {
|
||||
/// Create a logger for a given module path (call with `module_path!()`).
|
||||
pub const fn new(module: &'static str, min_level: LogLevel) -> Self {
|
||||
Self { module, min_level }
|
||||
}
|
||||
|
||||
/// Create a logger that shows everything (min_level = Trace).
|
||||
pub const fn verbose(module: &'static str) -> Self {
|
||||
Self::new(module, LogLevel::Trace)
|
||||
}
|
||||
|
||||
/// Format an ISO-like timestamp from `Date.now()`.
|
||||
fn timestamp() -> String {
|
||||
let d = js_sys::Date::new_0();
|
||||
// HH:MM:SS.mmm
|
||||
format!(
|
||||
"{:02}:{:02}:{:02}.{:03}",
|
||||
d.get_hours(),
|
||||
d.get_minutes(),
|
||||
d.get_seconds(),
|
||||
d.get_milliseconds()
|
||||
)
|
||||
}
|
||||
|
||||
fn should_log(&self, level: LogLevel) -> bool {
|
||||
level >= self.min_level
|
||||
}
|
||||
|
||||
fn log_inner(&self, level: LogLevel, message: &str) {
|
||||
if !self.should_log(level) {
|
||||
return;
|
||||
}
|
||||
let ts = Self::timestamp();
|
||||
let lvl_str = level.as_str();
|
||||
let style = level.console_style();
|
||||
let styled = format!("%c{:.7} [{}] {}", ts, self.module, message);
|
||||
match level {
|
||||
LogLevel::Error => {
|
||||
web_sys::console::error_3(
|
||||
&styled.into(),
|
||||
&style.into(),
|
||||
&"".into(),
|
||||
);
|
||||
}
|
||||
LogLevel::Warn => {
|
||||
web_sys::console::warn_3(
|
||||
&styled.into(),
|
||||
&style.into(),
|
||||
&"".into(),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
web_sys::console::log_3(
|
||||
&styled.into(),
|
||||
&style.into(),
|
||||
&"".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn trace(&self, msg: &str) {
|
||||
self.log_inner(LogLevel::Trace, msg);
|
||||
}
|
||||
|
||||
pub fn debug(&self, msg: &str) {
|
||||
self.log_inner(LogLevel::Debug, msg);
|
||||
}
|
||||
|
||||
pub fn info(&self, msg: &str) {
|
||||
self.log_inner(LogLevel::Info, msg);
|
||||
}
|
||||
|
||||
pub fn warn(&self, msg: &str) {
|
||||
self.log_inner(LogLevel::Warn, msg);
|
||||
}
|
||||
|
||||
pub fn error(&self, msg: &str) {
|
||||
self.log_inner(LogLevel::Error, msg);
|
||||
}
|
||||
|
||||
/// Log with a dynamic format string.
|
||||
pub fn info_fmt(&self, fmt: &str, args: &[&dyn std::fmt::Display]) {
|
||||
let msg = if args.is_empty() {
|
||||
fmt.to_string()
|
||||
} else {
|
||||
let mut s = String::new();
|
||||
let mut iter = args.iter();
|
||||
for part in fmt.split("{}") {
|
||||
s.push_str(part);
|
||||
if let Some(arg) = iter.next() {
|
||||
s.push_str(&arg.to_string());
|
||||
}
|
||||
}
|
||||
s
|
||||
};
|
||||
self.log_inner(LogLevel::Info, &msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Macro to create a module-level logger at `Info` level.
|
||||
/// Usage: `log::module!()` at the top of a source file (after imports).
|
||||
#[macro_export]
|
||||
macro_rules! make_logger {
|
||||
() => {
|
||||
static LOGGER: std::sync::LazyLock<$crate::logger::Logger> =
|
||||
std::sync::LazyLock::new(|| {
|
||||
$crate::logger::Logger::new(module_path!(), $crate::logger::LogLevel::Trace)
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/// Convenience macros that log through the module's static LOGGER.
|
||||
/// Usage: `log_info!("something happened")`.
|
||||
#[macro_export]
|
||||
macro_rules! log_trace {
|
||||
($($arg:tt)*) => { LOGGER.trace(&format!($($arg)*)); };
|
||||
}
|
||||
#[macro_export]
|
||||
macro_rules! log_debug {
|
||||
($($arg:tt)*) => { LOGGER.debug(&format!($($arg)*)); };
|
||||
}
|
||||
#[macro_export]
|
||||
macro_rules! log_info {
|
||||
($($arg:tt)*) => { LOGGER.info(&format!($($arg)*)); };
|
||||
}
|
||||
#[macro_export]
|
||||
macro_rules! log_warn {
|
||||
($($arg:tt)*) => { LOGGER.warn(&format!($($arg)*)); };
|
||||
}
|
||||
#[macro_export]
|
||||
macro_rules! log_error {
|
||||
($($arg:tt)*) => { LOGGER.error(&format!($($arg)*)); };
|
||||
}
|
||||
@@ -1,543 +0,0 @@
|
||||
/* ── Animations — Live Dashboard Effects ──────────────── *
|
||||
* Keyframes, applied animations, and utility classes. *
|
||||
* Respects prefers-reduced-motion. *
|
||||
* ──────────────────────────────────────────────────────── */
|
||||
|
||||
/* ════════════════════════════════════════════════════════
|
||||
1. KEYFRAMES
|
||||
════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Entry / Reveal ─────────────────────────────────── */
|
||||
@keyframes slide-in-left {
|
||||
from { opacity: 0; transform: translateX(-20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes slide-in-down {
|
||||
from { opacity: 0; transform: translateY(-12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes scale-bounce {
|
||||
0% { opacity: 0; transform: scale(0.85); }
|
||||
60% { opacity: 1; transform: scale(1.04); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes pop-in {
|
||||
0% { opacity: 0; transform: scale(0.8); }
|
||||
70% { opacity: 1; transform: scale(1.08); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ── Status / Alive ─────────────────────────────────── */
|
||||
@keyframes breathe {
|
||||
0%, 100% { opacity: 1; transform: scale(1); box-shadow: 0 0 4px currentColor; }
|
||||
50% { opacity: 0.6; transform: scale(1.2); box-shadow: 0 0 14px currentColor; }
|
||||
}
|
||||
|
||||
@keyframes breathe-soft {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes ring-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(59, 130, 246, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); }
|
||||
}
|
||||
|
||||
@keyframes ring-pulse-success {
|
||||
0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(16, 185, 129, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
|
||||
}
|
||||
|
||||
@keyframes ring-pulse-error {
|
||||
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(239, 68, 68, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
|
||||
}
|
||||
|
||||
@keyframes processing-sweep {
|
||||
0% { background-position: -200% center; }
|
||||
100% { background-position: 200% center; }
|
||||
}
|
||||
|
||||
@keyframes live-indicator {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 6px rgba(239, 68, 68, 0.4); }
|
||||
50% { opacity: 0.7; box-shadow: 0 0 16px rgba(239, 68, 68, 0.7); }
|
||||
}
|
||||
|
||||
/* ── Ambient / Decorative ───────────────────────────── */
|
||||
/* Slow breathing for background glow layers */
|
||||
@keyframes ambient-glow {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
/* Dynamic mesh drift — shifts gradient center positions */
|
||||
@keyframes bg-mesh-drift {
|
||||
0% { background-position: 0% 0%, 100% 100%, 50% 50%; }
|
||||
25% { background-position: 30% 20%, 70% 80%, 20% 60%; }
|
||||
50% { background-position: 60% 10%, 40% 60%, 80% 30%; }
|
||||
75% { background-position: 20% 60%, 80% 20%, 40% 80%; }
|
||||
100% { background-position: 0% 0%, 100% 100%, 50% 50%; }
|
||||
}
|
||||
|
||||
/* Slow hue rotation for orb glow */
|
||||
@keyframes hue-rotate-slow {
|
||||
0% { filter: hue-rotate(0deg); }
|
||||
50% { filter: hue-rotate(30deg); }
|
||||
100% { filter: hue-rotate(0deg); }
|
||||
}
|
||||
|
||||
/* Orb size pulsing */
|
||||
@keyframes orb-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 0.4; }
|
||||
50% { transform: scale(1.08); opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* Expanded orb drift — wider, slower, organic */
|
||||
@keyframes orb-drift-enhanced {
|
||||
0% { transform: translate(0, 0) scale(1); }
|
||||
20% { transform: translate(80px, -50px) scale(1.1); }
|
||||
40% { transform: translate(-50px, 70px) scale(0.9); }
|
||||
60% { transform: translate(100px, 30px) scale(1.05); }
|
||||
80% { transform: translate(-70px, -60px) scale(0.95); }
|
||||
100% { transform: translate(0, 0) scale(1); }
|
||||
}
|
||||
|
||||
/* Grain/noise texture shifting */
|
||||
@keyframes grain-shift {
|
||||
0%, 100% { transform: translate(0, 0) rotate(0deg); }
|
||||
10% { transform: translate(-5%, -5%) rotate(0.5deg); }
|
||||
20% { transform: translate(-10%, 0%) rotate(-0.5deg); }
|
||||
30% { transform: translate(0%, 5%) rotate(1deg); }
|
||||
40% { transform: translate(5%, -3%) rotate(-0.3deg); }
|
||||
50% { transform: translate(-3%, -8%) rotate(0.8deg); }
|
||||
60% { transform: translate(8%, 3%) rotate(-0.6deg); }
|
||||
70% { transform: translate(-6%, 6%) rotate(0.4deg); }
|
||||
80% { transform: translate(4%, -6%) rotate(-0.7deg); }
|
||||
90% { transform: translate(-2%, 2%) rotate(0.2deg); }
|
||||
}
|
||||
|
||||
@keyframes gradient-shimmer {
|
||||
0% { background-position: 0% center; }
|
||||
100% { background-position: 200% center; }
|
||||
}
|
||||
|
||||
@keyframes float-variation {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
33% { transform: translateY(-8px) rotate(1.5deg); }
|
||||
66% { transform: translateY(-4px) rotate(-1deg); }
|
||||
}
|
||||
|
||||
@keyframes fade-in-down {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ── Interactive ────────────────────────────────────── */
|
||||
@keyframes glow-border {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); }
|
||||
50% { box-shadow: 0 0 12px 1px rgba(59, 130, 246, 0.15); }
|
||||
}
|
||||
|
||||
@keyframes ripple {
|
||||
0% { transform: scale(0); opacity: 0.6; }
|
||||
100% { transform: scale(4); opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Bar pulses (audio / visualizer) ────────────────── */
|
||||
@keyframes bar-pulse-1 {
|
||||
0%, 100% { transform: scaleY(1); }
|
||||
50% { transform: scaleY(0.4); }
|
||||
}
|
||||
@keyframes bar-pulse-2 {
|
||||
0%, 100% { transform: scaleY(0.5); }
|
||||
50% { transform: scaleY(1.2); }
|
||||
}
|
||||
@keyframes bar-pulse-3 {
|
||||
0%, 100% { transform: scaleY(0.7); }
|
||||
50% { transform: scaleY(1); }
|
||||
}
|
||||
|
||||
@keyframes count-up {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
|
||||
/* ════════════════════════════════════════════════════════
|
||||
2. UTILITY ANIMATION CLASSES
|
||||
Use these in Rust components: class="animate-breathe"
|
||||
════════════════════════════════════════════════════════ */
|
||||
|
||||
.animate-breathe {
|
||||
animation: breathe 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-breathe-soft {
|
||||
animation: breathe-soft 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-ring-pulse {
|
||||
animation: ring-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-live-indicator {
|
||||
animation: live-indicator 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float-variation 5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-gradient-shimmer {
|
||||
background-size: 200% 100%;
|
||||
animation: gradient-shimmer 4s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin 3s linear infinite;
|
||||
}
|
||||
|
||||
.animate-pulse-connecting {
|
||||
animation: pulse-connecting 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-scale-bounce {
|
||||
animation: scale-bounce 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
|
||||
.animate-pop-in {
|
||||
animation: pop-in 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
|
||||
.animate-fade-in-down {
|
||||
animation: fade-in-down var(--transition-normal) ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-in-left {
|
||||
animation: slide-in-left var(--transition-normal) ease-out;
|
||||
}
|
||||
|
||||
.animate-count-up {
|
||||
animation: count-up 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.animate-shimmer-sweep {
|
||||
background: linear-gradient(
|
||||
110deg,
|
||||
transparent 30%,
|
||||
rgba(59, 130, 246, 0.08) 50%,
|
||||
transparent 70%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: processing-sweep 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
|
||||
/* ════════════════════════════════════════════════════════
|
||||
3. APPLIED ANIMATIONS — auto-wired to selectors
|
||||
════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Ambient animated mesh background ────────────────── */
|
||||
.app-shell::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: -50%;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 30%, rgba(59, 130, 246, 0.06) 0%, transparent 40%),
|
||||
radial-gradient(ellipse at 80% 70%, rgba(99, 102, 241, 0.05) 0%, transparent 40%),
|
||||
radial-gradient(ellipse at 40% 80%, rgba(16, 185, 129, 0.03) 0%, transparent 40%),
|
||||
radial-gradient(ellipse at 60% 20%, rgba(245, 158, 11, 0.02) 0%, transparent 40%);
|
||||
background-size: 200% 200%, 200% 200%, 200% 200%, 200% 200%;
|
||||
animation: bg-mesh-drift 20s ease-in-out infinite alternate;
|
||||
will-change: background-position;
|
||||
}
|
||||
|
||||
/* ── Subtle noise/grain texture overlay ─────────────── */
|
||||
.app-shell::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: -50%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 0.015;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
background-repeat: repeat;
|
||||
background-size: 256px 256px;
|
||||
animation: grain-shift 0.5s steps(4) infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* ── Light theme mesh adjustment ────────────────────── */
|
||||
[data-theme="light"] .app-shell::before {
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 30%, rgba(37, 99, 235, 0.04) 0%, transparent 40%),
|
||||
radial-gradient(ellipse at 80% 70%, rgba(99, 102, 241, 0.04) 0%, transparent 40%),
|
||||
radial-gradient(ellipse at 40% 80%, rgba(16, 185, 129, 0.03) 0%, transparent 40%),
|
||||
radial-gradient(ellipse at 60% 20%, rgba(245, 158, 11, 0.02) 0%, transparent 40%);
|
||||
}
|
||||
[data-theme="light"] .app-shell::after {
|
||||
opacity: 0.008;
|
||||
}
|
||||
|
||||
/* ── Gradient text shimmer (brand elements) ─────────── */
|
||||
.sidebar-brand-name,
|
||||
.live-title,
|
||||
.auth-title {
|
||||
background-size: 200% 100%;
|
||||
animation: gradient-shimmer 6s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
/* ── Connected status dot: alive breath ─────────────── */
|
||||
.status-dot,
|
||||
.voice-connection-dot.connected {
|
||||
animation: breathe 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Keep existing connecting animation (overrides above) */
|
||||
.status-dot.is-connecting,
|
||||
.voice-connection-dot.connecting {
|
||||
animation: pulse-connecting 1s ease-in-out infinite !important;
|
||||
}
|
||||
|
||||
.status-dot.disconnected,
|
||||
.voice-connection-dot.disconnected {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
/* ── Live indicator (red recording dot) ─────────────── */
|
||||
.msg-card-ai-badge.flagged::before,
|
||||
.msg-analysis.flagged::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-error);
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
animation: live-indicator 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── Particle orbs: ambient breathing overlay ───────── */
|
||||
.particle-bg {
|
||||
animation: ambient-glow 6s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
/* ── Live bento grid: staggered entry ───────────────── */
|
||||
.live-bento > * {
|
||||
animation: fade-in-up 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
.live-bento > :nth-child(1) { animation-delay: 0.03s; }
|
||||
.live-bento > :nth-child(2) { animation-delay: 0.06s; }
|
||||
.live-bento > :nth-child(3) { animation-delay: 0.09s; }
|
||||
.live-bento > :nth-child(4) { animation-delay: 0.12s; }
|
||||
.live-bento > :nth-child(5) { animation-delay: 0.15s; }
|
||||
.live-bento > :nth-child(6) { animation-delay: 0.18s; }
|
||||
|
||||
/* ── Dashboard metric cards: staggered entry ────────── */
|
||||
.dashboard-metric-card {
|
||||
animation: fade-in-up 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
.dashboard-metric-card:nth-child(1) { animation-delay: 0.04s; }
|
||||
.dashboard-metric-card:nth-child(2) { animation-delay: 0.08s; }
|
||||
.dashboard-metric-card:nth-child(3) { animation-delay: 0.12s; }
|
||||
.dashboard-metric-card:nth-child(4) { animation-delay: 0.16s; }
|
||||
.dashboard-metric-card:nth-child(5) { animation-delay: 0.20s; }
|
||||
.dashboard-metric-card:nth-child(6) { animation-delay: 0.24s; }
|
||||
.dashboard-metric-card:nth-child(7) { animation-delay: 0.28s; }
|
||||
.dashboard-metric-card:nth-child(8) { animation-delay: 0.32s; }
|
||||
|
||||
/* ── Dashboard rows: staggered fade-in ──────────────── */
|
||||
.dashboard-summary-row,
|
||||
.dashboard-top-channel-row {
|
||||
animation: fade-in 0.4s ease-out both;
|
||||
}
|
||||
.dashboard-summary-row:nth-child(1), .dashboard-top-channel-row:nth-child(1) { animation-delay: 0.02s; }
|
||||
.dashboard-summary-row:nth-child(2), .dashboard-top-channel-row:nth-child(2) { animation-delay: 0.04s; }
|
||||
.dashboard-summary-row:nth-child(3), .dashboard-top-channel-row:nth-child(3) { animation-delay: 0.06s; }
|
||||
.dashboard-summary-row:nth-child(4), .dashboard-top-channel-row:nth-child(4) { animation-delay: 0.08s; }
|
||||
.dashboard-summary-row:nth-child(5), .dashboard-top-channel-row:nth-child(5) { animation-delay: 0.10s; }
|
||||
.dashboard-summary-row:nth-child(6), .dashboard-top-channel-row:nth-child(6) { animation-delay: 0.12s; }
|
||||
.dashboard-summary-row:nth-child(7), .dashboard-top-channel-row:nth-child(7) { animation-delay: 0.14s; }
|
||||
.dashboard-summary-row:nth-child(8), .dashboard-top-channel-row:nth-child(8) { animation-delay: 0.16s; }
|
||||
.dashboard-summary-row:nth-child(9), .dashboard-top-channel-row:nth-child(9) { animation-delay: 0.18s; }
|
||||
.dashboard-summary-row:nth-child(10),.dashboard-top-channel-row:nth-child(10) { animation-delay: 0.20s; }
|
||||
|
||||
/* ── Message cards: staggered entry ─────────────────── */
|
||||
.msg-card {
|
||||
animation: fade-in-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
.msg-card:nth-child(1) { animation-delay: 0.02s; }
|
||||
.msg-card:nth-child(2) { animation-delay: 0.05s; }
|
||||
.msg-card:nth-child(3) { animation-delay: 0.08s; }
|
||||
.msg-card:nth-child(4) { animation-delay: 0.11s; }
|
||||
.msg-card:nth-child(5) { animation-delay: 0.14s; }
|
||||
.msg-card:nth-child(6) { animation-delay: 0.17s; }
|
||||
.msg-card:nth-child(7) { animation-delay: 0.20s; }
|
||||
.msg-card:nth-child(8) { animation-delay: 0.23s; }
|
||||
.msg-card:nth-child(9) { animation-delay: 0.26s; }
|
||||
.msg-card:nth-child(10) { animation-delay: 0.29s; }
|
||||
|
||||
/* ── Recording items: slide in from left ────────────── */
|
||||
.rec-item {
|
||||
animation: slide-in-left 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
.rec-item:nth-child(1) { animation-delay: 0.02s; }
|
||||
.rec-item:nth-child(2) { animation-delay: 0.06s; }
|
||||
.rec-item:nth-child(3) { animation-delay: 0.10s; }
|
||||
.rec-item:nth-child(4) { animation-delay: 0.14s; }
|
||||
.rec-item:nth-child(5) { animation-delay: 0.18s; }
|
||||
|
||||
/* ── Speaker items: pop in ──────────────────────────── */
|
||||
.speak-item {
|
||||
animation: fade-in 0.3s ease-out both;
|
||||
}
|
||||
.speak-item:nth-child(1) { animation-delay: 0.02s; }
|
||||
.speak-item:nth-child(2) { animation-delay: 0.05s; }
|
||||
.speak-item:nth-child(3) { animation-delay: 0.08s; }
|
||||
.speak-item:nth-child(4) { animation-delay: 0.11s; }
|
||||
.speak-item:nth-child(5) { animation-delay: 0.14s; }
|
||||
|
||||
/* ── Audio visualizer bars: individual timing ───────── */
|
||||
.audio-bar {
|
||||
animation: bar-pulse-2 1.2s ease-in-out infinite;
|
||||
}
|
||||
.audio-bar:nth-child(odd) {
|
||||
animation-name: bar-pulse-1;
|
||||
animation-duration: 0.9s;
|
||||
}
|
||||
.audio-bar:nth-child(3n) {
|
||||
animation-name: bar-pulse-3;
|
||||
animation-duration: 1.5s;
|
||||
}
|
||||
|
||||
/* ── Interactive cards: hover glow effect ────────────── */
|
||||
.card-interactive:hover {
|
||||
animation: glow-border 0.8s ease-in-out;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ── Nav active indicator: continuous subtle glow ───── */
|
||||
.sidebar-nav-item.is-active::after {
|
||||
animation: indicator-grow 250ms cubic-bezier(0.16, 1, 0.3, 1) both,
|
||||
breathe-soft 3s ease-in-out 0.3s infinite;
|
||||
}
|
||||
|
||||
/* ── Filter chip active: pop scale ──────────────────── */
|
||||
.filter-chip-v2.is-active {
|
||||
animation: pop-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
|
||||
/* ── AI badge processing: shimmer sweep ─────────────── */
|
||||
.msg-card-ai-badge.processing,
|
||||
.status-badge-processing {
|
||||
background: linear-gradient(
|
||||
110deg,
|
||||
var(--color-primary-muted) 25%,
|
||||
rgba(59, 130, 246, 0.35) 50%,
|
||||
var(--color-primary-muted) 75%
|
||||
) !important;
|
||||
background-size: 200% 100% !important;
|
||||
animation: processing-sweep 1.4s ease-in-out infinite;
|
||||
}
|
||||
.status-badge-processing::before {
|
||||
animation: ring-pulse 1.2s ease-in-out infinite !important;
|
||||
}
|
||||
|
||||
/* ── Image grid items: reveal ───────────────────────── */
|
||||
.image-grid-item {
|
||||
animation: fade-in 0.4s ease-out both;
|
||||
}
|
||||
.image-grid-item:nth-child(1) { animation-delay: 0.02s; }
|
||||
.image-grid-item:nth-child(2) { animation-delay: 0.05s; }
|
||||
.image-grid-item:nth-child(3) { animation-delay: 0.08s; }
|
||||
.image-grid-item:nth-child(4) { animation-delay: 0.11s; }
|
||||
.image-grid-item:nth-child(5) { animation-delay: 0.14s; }
|
||||
.image-grid-item:nth-child(6) { animation-delay: 0.17s; }
|
||||
.image-grid-item:nth-child(7) { animation-delay: 0.20s; }
|
||||
.image-grid-item:nth-child(8) { animation-delay: 0.23s; }
|
||||
|
||||
/* ── Dashboard queue value: count-up feel ───────────── */
|
||||
.dashboard-queue-value {
|
||||
animation: count-up 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
/* ── Voice connection panel: entry ──────────────────── */
|
||||
.voice-connection {
|
||||
animation: fade-in-up 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
/* ── Now playing: slide in ──────────────────────────── */
|
||||
.np-body {
|
||||
animation: slide-in-left 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
/* ── Skeleton: enhanced shimmer ─────────────────────── */
|
||||
.skeleton,
|
||||
.msg-skel-avatar,
|
||||
.msg-skel-line,
|
||||
.msg-skel-badge,
|
||||
.dashboard-skel-avatar,
|
||||
.dashboard-skel-line {
|
||||
background: linear-gradient(
|
||||
110deg,
|
||||
var(--surface-container) 28%,
|
||||
rgba(59, 130, 246, 0.06) 48%,
|
||||
var(--surface-container) 68%
|
||||
) !important;
|
||||
background-size: 200% 100% !important;
|
||||
}
|
||||
|
||||
/* ── Tab content: stronger entry ────────────────────── */
|
||||
.tab-content {
|
||||
animation: fade-in-up 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
/* ── Mobile tab active: subtle indicator ────────────── */
|
||||
.mobile-tab-item.is-active {
|
||||
animation: pop-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
|
||||
/* ── Mascot launcher: enhanced float ────────────────── */
|
||||
.mascot-launcher {
|
||||
animation: float-variation 5s ease-in-out infinite;
|
||||
}
|
||||
.mascot-launcher:hover {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
/* ── Empty state icons: subtle float ────────────────── */
|
||||
.empty-state-icon {
|
||||
animation: float-variation 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── Recent message action buttons ──────────────────── */
|
||||
.btn-icon-sm:active:not(:disabled),
|
||||
.btn-icon:active:not(:disabled) {
|
||||
animation: ripple 0.4s ease-out;
|
||||
}
|
||||
|
||||
/* ── Small decorative: channel list rows ────────────── */
|
||||
.dashboard-top-channel-row:hover {
|
||||
animation: none; /* Keep the existing hover translateX */
|
||||
}
|
||||
|
||||
|
||||
/* ════════════════════════════════════════════════════════
|
||||
4. RESPECT REDUCED MOTION
|
||||
════════════════════════════════════════════════════════ */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,204 +0,0 @@
|
||||
/* ── Dashboard Panel ──────────────────────────────────── */
|
||||
|
||||
.dashboard-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
/* ── Stats Overview (4-column grid) ──────────────────── */
|
||||
.dashboard-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.dashboard-metric-card {
|
||||
padding: var(--space-5);
|
||||
overflow: hidden;
|
||||
height: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.dashboard-metric-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.dashboard-metric-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.dashboard-metric-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dashboard-metric-value {
|
||||
margin-top: var(--space-1);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.dashboard-metric-trend {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
.dashboard-metric-trend.positive { color: var(--color-success); }
|
||||
.dashboard-metric-trend.negative { color: var(--color-error); }
|
||||
|
||||
.dashboard-metric-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* ── Summary Lists ───────────────────────────────────── */
|
||||
.dashboard-wide-card { grid-column: span 2; }
|
||||
|
||||
.dashboard-summary-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.dashboard-summary-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.dashboard-summary-row:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.dashboard-summary-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-container);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dashboard-summary-avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.dashboard-channel-avatar {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-muted);
|
||||
}
|
||||
|
||||
.dashboard-summary-main { min-width: 0; flex: 1; }
|
||||
.dashboard-summary-title {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dashboard-summary-text {
|
||||
margin-top: var(--space-1);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.dashboard-summary-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.dashboard-list-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-10) var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Skeleton cells ──────────────────────────────────── */
|
||||
.dashboard-skel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.dashboard-skel-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(90deg, var(--surface-container) 25%, rgba(59, 130, 246, 0.04) 50%, var(--surface-container) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
.dashboard-skel-lines {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.dashboard-skel-line {
|
||||
height: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(90deg, var(--surface-container) 25%, rgba(59, 130, 246, 0.04) 50%, var(--surface-container) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────── */
|
||||
@media (max-width: 1024px) {
|
||||
.dashboard-stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-stats-grid { grid-template-columns: 1fr; }
|
||||
.dashboard-wide-card { grid-column: span 1; }
|
||||
}
|
||||
|
||||
/* ── Legacy aliases ──────────────────────────────────── */
|
||||
.dashboard-stats { display: flex; flex-wrap: wrap; gap: var(--space-4); }
|
||||
.dashboard-top-channels, .dashboard-summary-list { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.dashboard-top-channel-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm); background: var(--surface-container); color: var(--text-secondary); font-size: 0.875rem; transition: all var(--transition-fast); }
|
||||
.dashboard-top-channel-row:hover { background: var(--surface-overlay); transform: translateX(2px); }
|
||||
.dashboard-moderation-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); }
|
||||
.dashboard-queue-value { font-size: 1.75rem; font-weight: 800; color: var(--text-primary); letter-spacing: -0.03em; }
|
||||
.dashboard-queue-label { margin-top: var(--space-1); color: var(--text-secondary); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.dashboard-list-card { overflow: hidden; }
|
||||
.dashboard-list-toolbar { margin-bottom: var(--space-4); }
|
||||
|
||||
.badge-secondary { background: var(--color-primary-muted); color: var(--color-primary); }
|
||||
@@ -1,274 +0,0 @@
|
||||
/* ── App Shell & Layout ───────────────────────────────── */
|
||||
|
||||
.app-shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Sidebar ─────────────────────────────────────────── */
|
||||
.app-sidebar {
|
||||
width: var(--sidebar-width);
|
||||
flex-shrink: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--surface-border);
|
||||
background: var(--surface-glass);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-sidebar);
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
height: 60px;
|
||||
padding: 0 var(--space-4);
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.sidebar-brand-icon {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sidebar-brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-brand-name {
|
||||
font-weight: 800;
|
||||
font-size: 1rem;
|
||||
background: var(--gradient-brand);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sidebar-brand-subtitle {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* ── Navigation ──────────────────────────────────────── */
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-3);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
height: 40px;
|
||||
padding: 0 var(--space-3);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.sidebar-nav-item:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.sidebar-nav-item.is-active {
|
||||
background: var(--surface-overlay);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-nav-item.is-active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--gradient-primary);
|
||||
box-shadow: 0 0 12px rgba(59, 130, 246, 0.4);
|
||||
animation: indicator-grow 250ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.sidebar-nav-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* ── Sidebar Footer ──────────────────────────────────── */
|
||||
.sidebar-footer {
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-3);
|
||||
border-top: 1px solid var(--surface-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.sidebar-footer-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.status-dot.is-connecting {
|
||||
animation: pulse-connecting 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Content Area ────────────────────────────────────── */
|
||||
.app-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-6);
|
||||
max-width: 1400px;
|
||||
animation: content-enter 500ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.app-content > * {
|
||||
animation: card-in 500ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
.app-content > :nth-child(1) { animation-delay: 30ms; }
|
||||
.app-content > :nth-child(2) { animation-delay: 60ms; }
|
||||
.app-content > :nth-child(3) { animation-delay: 90ms; }
|
||||
|
||||
/* ── Theme Toggle in Sidebar ──────────────────────────── */
|
||||
.sidebar-theme-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-overlay);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-theme-btn:hover {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ── Mobile Tab Bar ──────────────────────────────────── */
|
||||
.mobile-tab-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-main { flex-direction: column; }
|
||||
.app-sidebar { display: none; }
|
||||
.app-content { padding: var(--space-4); }
|
||||
|
||||
.mobile-tab-bar {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--surface-base);
|
||||
border-top: 1px solid var(--surface-border);
|
||||
z-index: var(--z-overlay);
|
||||
}
|
||||
|
||||
.mobile-tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
font-size: 0.625rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
transition: color var(--transition-fast);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.mobile-tab-item.is-active {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.mobile-tab-item-icon {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Animations ──────────────────────────────────────── */
|
||||
@keyframes content-enter {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes card-in {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes indicator-grow {
|
||||
from { height: 0; opacity: 0; }
|
||||
to { height: 24px; opacity: 1; }
|
||||
}
|
||||
@keyframes pulse-connecting {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.7); }
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
/* ── Live Panel (Bento Grid) ──────────────────────────── */
|
||||
|
||||
.live-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.live-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.live-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
background: var(--gradient-brand);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.live-desc {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
/* ── Bento Grid ──────────────────────────────────────── */
|
||||
.live-grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
/* 2-column bento layout */
|
||||
.live-bento {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.live-bento > .live-span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.live-bento { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ── Voice Connection Card ───────────────────────────── */
|
||||
.voice-connection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.voice-connection-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.voice-connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.voice-connection-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.voice-connection-dot.connected { background: var(--color-success); box-shadow: 0 0 8px rgba(16, 185, 129, 0.4); }
|
||||
.voice-connection-dot.connecting { background: var(--color-warning); animation: pulse-connecting 1s ease-in-out infinite; }
|
||||
.voice-connection-dot.disconnected { background: var(--text-tertiary); }
|
||||
|
||||
/* ── Active Speakers ─────────────────────────────────── */
|
||||
.speak-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.speak-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
.speak-item:hover { background: var(--surface-hover); }
|
||||
|
||||
.speak-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-container);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.speak-info { flex: 1; min-width: 0; }
|
||||
.speak-name { font-size: 0.8125rem; font-weight: 500; color: var(--text-primary); }
|
||||
.speak-status { font-size: 0.6875rem; color: var(--text-tertiary); }
|
||||
|
||||
.speak-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.speak-bar {
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
border-radius: 1px;
|
||||
background: var(--color-primary);
|
||||
animation: bar-pulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
.speak-bar:nth-child(2) { animation-delay: 0.1s; }
|
||||
.speak-bar:nth-child(3) { animation-delay: 0.2s; }
|
||||
.speak-bar:nth-child(4) { animation-delay: 0.3s; }
|
||||
.speak-bar:nth-child(5) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes bar-pulse {
|
||||
0%, 100% { transform: scaleY(1); }
|
||||
50% { transform: scaleY(0.5); }
|
||||
}
|
||||
|
||||
/* ── Audio Visualizer ────────────────────────────────── */
|
||||
.audio-visualizer-canvas {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-container);
|
||||
}
|
||||
|
||||
/* ── Mic Level Meter ─────────────────────────────────── */
|
||||
.mic-level {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.mic-track {
|
||||
position: relative;
|
||||
height: var(--space-2);
|
||||
border-radius: var(--radius-pill);
|
||||
overflow: hidden;
|
||||
background: var(--surface-container);
|
||||
}
|
||||
.mic-fill {
|
||||
height: 100%;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--gradient-primary);
|
||||
transition: width 100ms ease;
|
||||
}
|
||||
.mic-clip {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 2px;
|
||||
background: white;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* ── Music Player / Now Playing ──────────────────────── */
|
||||
.np-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.np-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.np-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.np-sep {
|
||||
border-top: 1px solid var(--surface-border);
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
.wave-wrap {
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.wave-progress {
|
||||
height: var(--space-2);
|
||||
border-radius: var(--radius-pill);
|
||||
overflow: hidden;
|
||||
background: var(--surface-container);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.wave-bar {
|
||||
height: 100%;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--gradient-primary);
|
||||
transition: width 200ms ease;
|
||||
}
|
||||
.wave-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.wave-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
max-width: 192px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wave-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* ── Screen Share Panel ──────────────────────────────── */
|
||||
.scrn-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.scrn-status {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
/* ── Recordings Panel ────────────────────────────────── */
|
||||
.rec-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.rec-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--surface-border);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.rec-item:hover {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--color-primary);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
.rec-info { flex: 1; min-width: 0; }
|
||||
.rec-name { font-size: 0.875rem; font-weight: 500; }
|
||||
.rec-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
.rec-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rec-footer { margin-top: var(--space-3); text-align: center; }
|
||||
.rec-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-8);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.music-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* ── Legacy aliases ──────────────────────────────────── */
|
||||
.live-panel { display: flex; flex-direction: column; gap: var(--space-6); }
|
||||
.live-grid-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
|
||||
.recordings-sub-panel { overflow: hidden; }
|
||||
|
||||
.audio-visualizer { display: flex; align-items: flex-end; justify-content: center; height: 80px; gap: 2px; padding: var(--space-2); }
|
||||
.audio-visualizer-bars { display: flex; align-items: flex-end; gap: 2px; height: 100%; width: 100%; }
|
||||
.audio-bar { width: 8px; background: var(--gradient-primary); border-radius: var(--radius-sm) var(--radius-sm) 0 0; transition: height 100ms ease; }
|
||||
|
||||
.mic-level-meter { display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.mic-row { display: flex; align-items: center; gap: var(--space-2); }
|
||||
|
||||
@media (max-width: 1024px) { .live-grid-3 { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 768px) { .live-grid-3 { grid-template-columns: 1fr; } }
|
||||
@@ -1,13 +0,0 @@
|
||||
/* ── IMPHNEN Design System — Main Import Hub ─────────── *
|
||||
* Order is guaranteed by @import sequence. *
|
||||
* ─────────────────────────────────────────────────────── */
|
||||
|
||||
@import './tokens.css';
|
||||
@import './reset.css';
|
||||
@import './utilities.css';
|
||||
@import './layout.css';
|
||||
@import './ui.css';
|
||||
@import './messages.css';
|
||||
@import './dashboard.css';
|
||||
@import './live.css';
|
||||
@import './polish.css';
|
||||
@@ -1,612 +0,0 @@
|
||||
/* ── Messages Panel ───────────────────────────────────── */
|
||||
|
||||
.messages-panel { display: flex; flex-direction: column; gap: var(--space-5); }
|
||||
|
||||
/* ── Filter Bar ──────────────────────────────────────── */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.filter-bar-search {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.filter-bar-search-icon {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-tertiary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.filter-bar-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
|
||||
height: 36px;
|
||||
background: var(--surface-container);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
transition: all var(--transition-fast);
|
||||
outline: none;
|
||||
}
|
||||
.filter-bar-input::placeholder { color: var(--text-tertiary); }
|
||||
.filter-bar-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-muted);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.filter-bar-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid transparent;
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
font-family: inherit;
|
||||
}
|
||||
.filter-chip:hover {
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface-container);
|
||||
}
|
||||
.filter-chip.is-active {
|
||||
background: var(--gradient-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.filter-bar-live-count {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Stats Bar ───────────────────────────────────────── */
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
/* ── Message Card ────────────────────────────────────── */
|
||||
.msg-card {
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
transition: all var(--transition-fast);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.msg-card:hover {
|
||||
border-color: rgba(59, 130, 246, 0.20);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.msg-card.is-deleted {
|
||||
opacity: 0.6;
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.msg-card-inner {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.msg-card-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.msg-card-body { min-width: 0; flex: 1; }
|
||||
|
||||
.msg-card-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.msg-card-username {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.msg-card-channel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-tertiary);
|
||||
background: var(--surface-container);
|
||||
padding: 1px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
.msg-card-channel::before { content: '#'; opacity: 0.5; }
|
||||
|
||||
.msg-card-time {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── AI Badge ────────────────────────────────────────── */
|
||||
.msg-card-ai-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
margin-left: var(--space-1);
|
||||
}
|
||||
.msg-card-ai-badge.clean {
|
||||
background: rgba(16, 185, 129, 0.10);
|
||||
color: var(--color-ai-clean);
|
||||
}
|
||||
.msg-card-ai-badge.warn {
|
||||
background: rgba(245, 158, 11, 0.10);
|
||||
color: var(--color-ai-warn);
|
||||
}
|
||||
.msg-card-ai-badge.flagged {
|
||||
background: rgba(239, 68, 68, 0.10);
|
||||
color: var(--color-ai-flagged);
|
||||
}
|
||||
.msg-card-ai-badge.processing {
|
||||
background: var(--color-primary-muted);
|
||||
color: var(--color-ai-processing);
|
||||
}
|
||||
.msg-card-ai-badge.pending {
|
||||
background: rgba(92, 92, 120, 0.10);
|
||||
color: var(--color-ai-pending);
|
||||
}
|
||||
|
||||
/* ── Reply indicator (Discord-style reference block) ──── */
|
||||
.msg-row-reply {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
margin: 0.375rem 0 0.625rem;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
.msg-row-reply:hover {
|
||||
background: rgba(59, 130, 246, 0.04);
|
||||
}
|
||||
.msg-row-reply-line {
|
||||
width: 3px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(180deg, #3b82f6, #8b5cf6);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.msg-row-reply-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.msg-row-reply-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #3b82f6, #6366f1);
|
||||
text-transform: uppercase;
|
||||
box-shadow: 0 1px 3px rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
.msg-row-reply-label {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 450;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.msg-row-reply-user {
|
||||
flex-shrink: 0;
|
||||
font-weight: 700;
|
||||
color: #3b82f6;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.msg-row-reply-snippet {
|
||||
color: var(--text-tertiary);
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
opacity: 0.9;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
.msg-row-reply-snippet::before {
|
||||
content: '"';
|
||||
opacity: 0.5;
|
||||
margin-right: 1px;
|
||||
}
|
||||
.msg-row-reply-snippet::after {
|
||||
content: '"';
|
||||
opacity: 0.5;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
/* ── Message Content ─────────────────────────────────── */
|
||||
.msg-card-content {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.msg-card-content.is-deleted {
|
||||
opacity: 0.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.msg-card-messages.separated > * + * {
|
||||
margin-top: 0.625rem;
|
||||
padding-top: 0.625rem;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
/* ── Actions Bar ─────────────────────────────────────── */
|
||||
.msg-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: 1px solid var(--surface-border);
|
||||
background: var(--surface-base);
|
||||
}
|
||||
|
||||
.msg-card-actions .btn-icon-sm {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.msg-card-actions .btn-icon-sm:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ── Embed ───────────────────────────────────────────── */
|
||||
.msg-embed {
|
||||
margin-top: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border-left: 3px solid var(--color-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-overlay);
|
||||
}
|
||||
.msg-embed-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
.msg-embed-description {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.msg-embed-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.msg-embed-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.msg-embed-field-name {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
.msg-embed-field-value {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.msg-embed-footer {
|
||||
margin-top: var(--space-2);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ── Image Grid ──────────────────────────────────────── */
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.image-grid-item {
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
border: 1px solid var(--surface-border);
|
||||
}
|
||||
.image-grid-item:hover {
|
||||
opacity: 0.85;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
.image-grid-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* ── Message Media ───────────────────────────────────── */
|
||||
.msg-media-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.msg-media-row.is-scroll {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.msg-thumb {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--surface-border);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.msg-thumb:hover { transform: scale(1.08); }
|
||||
.msg-thumb-link {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--surface-border);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.msg-thumb-link:hover { border-color: var(--color-primary); }
|
||||
.msg-sticker {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.msg-video {
|
||||
height: 112px;
|
||||
width: 192px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--surface-border);
|
||||
object-fit: cover;
|
||||
background: #000;
|
||||
}
|
||||
.msg-media-overflow {
|
||||
display: flex;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--surface-border);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface-container);
|
||||
}
|
||||
.msg-media-overflow.tall { height: 112px; width: 64px; }
|
||||
|
||||
/* ── Message Categories ──────────────────────────────── */
|
||||
.msg-cats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* ── AI Analysis ─────────────────────────────────────── */
|
||||
.msg-analysis {
|
||||
margin-top: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
border-left: 3px solid;
|
||||
}
|
||||
.msg-analysis.flagged {
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
border-color: var(--color-ai-flagged);
|
||||
}
|
||||
.msg-analysis.clean {
|
||||
background: rgba(16, 185, 129, 0.06);
|
||||
border-color: var(--color-ai-clean);
|
||||
}
|
||||
.msg-analysis-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.msg-analysis-body { min-width: 0; flex: 1; }
|
||||
.msg-analysis-summary {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
.msg-analysis-text {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ── Message Error ───────────────────────────────────── */
|
||||
.msg-error {
|
||||
margin-top: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-warning);
|
||||
background: rgba(245, 158, 11, 0.06);
|
||||
}
|
||||
|
||||
/* ── Message Skeleton ────────────────────────────────── */
|
||||
.msg-skel {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.msg-skel-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(90deg, var(--surface-container) 25%, rgba(59, 130, 246, 0.04) 50%, var(--surface-container) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.msg-skel-lines {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.msg-skel-line {
|
||||
height: 20px;
|
||||
background: linear-gradient(90deg, var(--surface-container) 25%, rgba(59, 130, 246, 0.04) 50%, var(--surface-container) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.msg-skel-badges {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.msg-skel-badge {
|
||||
height: 24px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: linear-gradient(90deg, var(--surface-container) 25%, rgba(59, 130, 246, 0.04) 50%, var(--surface-container) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── Feed ────────────────────────────────────────────── */
|
||||
.feed-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.feed-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-16) var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
.feed-empty-title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.feed-empty-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-tertiary);
|
||||
max-width: 280px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.feed-sentinel { height: var(--space-4); }
|
||||
.feed-loader { margin-top: var(--space-4); text-align: center; }
|
||||
|
||||
/* ── Misc ────────────────────────────────────────────── */
|
||||
.search-count {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.icon-spin { animation: spin 1s linear infinite; }
|
||||
.custom-emoji {
|
||||
display: inline-block;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
vertical-align: middle;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* ── Legacy aliases (components still use these) ────── */
|
||||
.panel-card { background: var(--surface-raised); border: 1px solid var(--surface-border); border-radius: var(--radius-md); overflow: hidden; box-shadow: var(--shadow-sm); transition: all var(--transition-normal); }
|
||||
.panel-card-head { padding: var(--space-5) var(--space-6); border-bottom: 1px solid var(--surface-border); }
|
||||
.panel-card-title { font-size: 1rem; font-weight: 600; color: var(--text-primary); }
|
||||
.panel-card-desc { font-size: 0.875rem; color: var(--text-secondary); margin-top: var(--space-1); }
|
||||
|
||||
.search-bar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
|
||||
.search-wrap { position: relative; flex: 1; min-width: 200px; }
|
||||
.search-icon { position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--text-tertiary); pointer-events: none; }
|
||||
.search-input { width: 100%; padding: 0.5rem 0.75rem 0.5rem 2.25rem; height: 36px; background: var(--surface-container); border: 1px solid transparent; border-radius: var(--radius-pill); color: var(--text-primary); font-family: inherit; font-size: 0.875rem; transition: all var(--transition-fast); outline: none; }
|
||||
.search-input::placeholder { color: var(--text-tertiary); }
|
||||
.search-input:focus { border-color: var(--color-primary); box-shadow: 0 0 0 2px var(--color-primary-muted); background: var(--surface-raised); }
|
||||
|
||||
.filter-group { display: flex; align-items: center; gap: 0.375rem; margin-left: auto; }
|
||||
.filter-chip-v2 { padding: 0.25rem 0.75rem; border-radius: var(--radius-pill); font-size: 0.6875rem; font-weight: 500; border: 1px solid transparent; background: var(--surface-hover); color: var(--text-tertiary); cursor: pointer; transition: all var(--transition-fast); font-family: inherit; }
|
||||
.filter-chip-v2:hover { color: var(--text-secondary); background: var(--surface-container); }
|
||||
.filter-chip-v2.is-active { background: var(--gradient-primary); color: white; }
|
||||
|
||||
.msg-row { padding: var(--space-3) 0; border-bottom: 1px solid var(--surface-border); transition: all var(--transition-fast); }
|
||||
.msg-row:hover { background: var(--surface-hover); margin: 0 calc(-1 * var(--space-4)); padding-left: var(--space-4); padding-right: var(--space-4); border-radius: var(--radius-sm); }
|
||||
.msg-row:last-child { border-bottom: none; }
|
||||
.msg-row-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem 0.5rem; }
|
||||
.msg-row-time { font-size: 0.6875rem; color: var(--text-tertiary); font-variant-numeric: tabular-nums; min-width: 48px; }
|
||||
.msg-row-badge { display: inline-flex; align-items: center; gap: 0.125rem; font-size: 0.75rem; }
|
||||
.msg-row-badge.edited { color: var(--text-secondary); }
|
||||
.msg-row-badge.deleted { color: var(--color-error); }
|
||||
.msg-row-status { margin-left: auto; display: flex; align-items: center; gap: var(--space-1); }
|
||||
.msg-row-body { font-size: 0.875rem; line-height: 1.5rem; white-space: pre-wrap; word-break: break-word; }
|
||||
.msg-row-body.is-deleted { opacity: 0.6; color: var(--text-secondary); }
|
||||
|
||||
.msg-card-head { display: flex; align-items: baseline; gap: var(--space-2); margin-bottom: var(--space-2); }
|
||||
.msg-card-name { font-size: 0.875rem; font-weight: 600; color: var(--text-primary); }
|
||||
.msg-card-messages > * + * { margin-top: 0.625rem; padding-top: 0.625rem; border-top: 1px solid var(--surface-border); }
|
||||
.msg-card-messages.separated > * + * { margin-top: 0.625rem; padding-top: 0.625rem; border-top: 1px solid var(--surface-border); }
|
||||
|
||||
.msg-actions { display: flex; align-items: center; gap: var(--space-2); margin-top: var(--space-2); }
|
||||
.msg-retry-hint { font-size: 0.75rem; color: var(--text-tertiary); opacity: 0.7; }
|
||||
|
||||
.msg-sticker-placeholder { display: flex; width: 48px; height: 48px; align-items: center; justify-content: center; border-radius: var(--radius-sm); border: 1px solid var(--surface-border); }
|
||||
|
||||
.msg-analysis-icon { margin-top: 0.125rem; flex-shrink: 0; }
|
||||
|
||||
.img-empty { display: flex; align-items: center; justify-content: center; height: 128px; color: var(--text-secondary); font-style: italic; }
|
||||
@@ -1,311 +0,0 @@
|
||||
/* ── Polish: Particles, Theme Toggle, Mascot ──────────── */
|
||||
|
||||
/* ── Particle Background ─────────────────────────────── */
|
||||
.particle-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.particle-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
will-change: transform, filter;
|
||||
}
|
||||
.particle-orb:nth-child(1) {
|
||||
width: 600px; height: 600px;
|
||||
top: -200px; right: -150px;
|
||||
background: radial-gradient(circle, rgba(59, 130, 246, 0.5) 0%, transparent 70%);
|
||||
filter: blur(100px);
|
||||
animation: orb-drift-enhanced 30s ease-in-out infinite,
|
||||
hue-rotate-slow 12s ease-in-out infinite,
|
||||
orb-pulse 8s ease-in-out infinite;
|
||||
}
|
||||
.particle-orb:nth-child(2) {
|
||||
width: 500px; height: 500px;
|
||||
bottom: -150px; left: -150px;
|
||||
background: radial-gradient(circle, rgba(99, 102, 241, 0.5) 0%, transparent 70%);
|
||||
filter: blur(100px);
|
||||
animation: orb-drift-enhanced 35s ease-in-out infinite reverse,
|
||||
hue-rotate-slow 14s ease-in-out infinite reverse,
|
||||
orb-pulse 10s ease-in-out infinite;
|
||||
animation-delay: -5s;
|
||||
}
|
||||
.particle-orb:nth-child(3) {
|
||||
width: 350px; height: 350px;
|
||||
top: 40%; left: 60%;
|
||||
background: radial-gradient(circle, rgba(99, 102, 241, 0.4) 0%, transparent 70%);
|
||||
filter: blur(100px);
|
||||
animation: orb-drift-enhanced 40s ease-in-out infinite,
|
||||
hue-rotate-slow 16s ease-in-out infinite,
|
||||
orb-pulse 6s ease-in-out infinite;
|
||||
animation-delay: -10s;
|
||||
}
|
||||
.particle-orb:nth-child(4) {
|
||||
width: 250px; height: 250px;
|
||||
top: 10%; left: 20%;
|
||||
background: radial-gradient(circle, rgba(16, 185, 129, 0.4) 0%, transparent 70%);
|
||||
filter: blur(100px);
|
||||
animation: orb-drift-enhanced 25s ease-in-out infinite reverse,
|
||||
hue-rotate-slow 18s ease-in-out infinite,
|
||||
orb-pulse 12s ease-in-out infinite;
|
||||
animation-delay: -3s;
|
||||
}
|
||||
|
||||
.particle-orb:nth-child(5) {
|
||||
width: 180px; height: 180px;
|
||||
top: 70%; left: 30%;
|
||||
background: radial-gradient(circle, rgba(59, 130, 246, 0.35) 0%, transparent 70%);
|
||||
filter: blur(80px);
|
||||
animation: orb-drift-enhanced 20s ease-in-out infinite,
|
||||
hue-rotate-slow 10s ease-in-out infinite,
|
||||
orb-pulse 5s ease-in-out infinite;
|
||||
animation-delay: -7s;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.particle-bg { display: none; }
|
||||
.app-shell::after { display: none; }
|
||||
}
|
||||
|
||||
/* ── Theme Toggle ────────────────────────────────────── */
|
||||
.theme-toggle-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-overlay);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.theme-toggle-btn:hover {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ── Mascot Chatbot ──────────────────────────────────── */
|
||||
.mascot-widget {
|
||||
position: fixed;
|
||||
right: var(--space-6);
|
||||
bottom: var(--space-6);
|
||||
z-index: var(--z-toast);
|
||||
}
|
||||
.mascot-launcher {
|
||||
width: 3.75rem;
|
||||
height: 3.75rem;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--gradient-primary);
|
||||
color: white;
|
||||
box-shadow: var(--shadow-lg), 0 0 24px rgba(59, 130, 246, 0.25);
|
||||
cursor: pointer;
|
||||
font-size: 1.5rem;
|
||||
transition: all var(--transition-fast);
|
||||
animation: float 4s ease-in-out infinite;
|
||||
}
|
||||
.mascot-launcher:hover {
|
||||
transform: translateY(-3px) scale(1.05);
|
||||
animation: none;
|
||||
}
|
||||
.mascot-panel {
|
||||
width: min(24rem, calc(100vw - 2rem));
|
||||
height: min(32.5rem, calc(100vh - 7rem));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: fade-in-up var(--transition-bounce);
|
||||
}
|
||||
.mascot-panel.minimized { height: 3.75rem; }
|
||||
.mascot-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--gradient-primary);
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mascot-header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
.mascot-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.mascot-subtitle { margin-top: 0.125rem; font-size: 0.6875rem; opacity: 0.7; }
|
||||
.mascot-icon-button {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
.mascot-icon-button:hover { background: rgba(255,255,255,0.20); }
|
||||
.mascot-messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.mascot-message-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
animation: fade-in-up var(--transition-fast) both;
|
||||
}
|
||||
.mascot-message-row.user { justify-content: flex-end; }
|
||||
.mascot-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-container);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.mascot-bubble {
|
||||
max-width: 17.5rem;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.mascot-bubble.user {
|
||||
color: white;
|
||||
background: var(--gradient-primary);
|
||||
border-bottom-right-radius: var(--radius-sm);
|
||||
}
|
||||
.mascot-bubble.mascot {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-container);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-bottom-left-radius: var(--radius-sm);
|
||||
}
|
||||
.mascot-bubble.typing {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding-top: 0.7rem;
|
||||
padding-bottom: 0.7rem;
|
||||
}
|
||||
.mascot-bubble.typing span {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
animation: notification-pulse 0.8s infinite;
|
||||
}
|
||||
.mascot-bubble.typing span:nth-child(2) { animation-delay: 0.1s; }
|
||||
.mascot-bubble.typing span:nth-child(3) { animation-delay: 0.2s; }
|
||||
@keyframes notification-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
.mascot-form {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border-top: 1px solid var(--surface-border);
|
||||
}
|
||||
.mascot-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1.5px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--surface-container);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.mascot-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-muted);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.mascot-send {
|
||||
width: 2.25rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--gradient-primary);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.mascot-send:hover { box-shadow: 0 0 12px rgba(59, 130, 246, 0.25); }
|
||||
.mascot-send:disabled, .mascot-input:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
|
||||
/* ── Float animation ─────────────────────────────────── */
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
/* ── Auth ────────────────────────────────────────────── */
|
||||
.auth-box { width: 400px; text-align: center; }
|
||||
.auth-lock { font-size: 3rem; margin-bottom: var(--space-4); }
|
||||
.auth-title {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--space-2);
|
||||
background: var(--gradient-brand);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.auth-desc {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
.auth-form { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.auth-error {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-error);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.auth-close-btn {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
line-height: 1;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
.auth-close-btn:hover { color: var(--text-primary); }
|
||||
@@ -1,48 +0,0 @@
|
||||
/* ── Reset & Base ─────────────────────────────────────── */
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
background: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
a:hover { color: var(--color-primary-hover); }
|
||||
|
||||
img { max-width: 100%; height: auto; }
|
||||
svg { display: inline-block; vertical-align: middle; }
|
||||
|
||||
/* ── Scrollbar ────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-primary);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(59, 130, 246, 0.25);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
[data-theme="light"] ::selection {
|
||||
background: rgba(37, 99, 235, 0.25);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
/* ── Design Tokens — IMPHNEN Neuform Dark ────────────── *
|
||||
* Dark default (no attribute selector) *
|
||||
* Light: [data-theme="light"] *
|
||||
* ──────────────────────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
/* ── Surfaces ──────────────────────────────────── */
|
||||
--surface-base: #050510;
|
||||
--surface-raised: #0a0a1a;
|
||||
--surface-overlay: #12122a;
|
||||
--surface-container: #1a1a35;
|
||||
--surface-border: rgba(255, 255, 255, 0.06);
|
||||
--surface-hover: rgba(59, 130, 246, 0.06);
|
||||
--surface-glass: rgba(5, 5, 16, 0.78);
|
||||
|
||||
/* ── Text ──────────────────────────────────────── */
|
||||
--text-primary: #f1f1f9;
|
||||
--text-secondary: #9d9db5;
|
||||
--text-tertiary: #5c5c78;
|
||||
--text-inverse: #050510;
|
||||
|
||||
/* ── Brand ─────────────────────────────────────── */
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-hover: #60a5fa;
|
||||
--color-primary-active: #2563eb;
|
||||
--color-primary-muted: rgba(59, 130, 246, 0.12);
|
||||
--gradient-primary: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%);
|
||||
--gradient-brand: linear-gradient(135deg, #3b82f6 0%, #5865f2 50%, #6366f1 100%);
|
||||
|
||||
/* ── Semantics ─────────────────────────────────── */
|
||||
--color-success: #10b981;
|
||||
--color-warning: #f59e0b;
|
||||
--color-error: #ef4444;
|
||||
--color-info: #3b82f6;
|
||||
--color-destructive: #ef4444;
|
||||
|
||||
/* ── AI Status ─────────────────────────────────── */
|
||||
--color-ai-flagged: #ef4444;
|
||||
--color-ai-clean: #10b981;
|
||||
--color-ai-warn: #f59e0b;
|
||||
--color-ai-pending: #5c5c78;
|
||||
--color-ai-processing: #3b82f6;
|
||||
--color-ai-error: #dc2626;
|
||||
--color-ai-deleted: #6b7280;
|
||||
|
||||
/* ── Shadows ───────────────────────────────────── */
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35);
|
||||
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.4);
|
||||
|
||||
/* ── Radii ─────────────────────────────────────── */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 23px;
|
||||
--radius-pill: 9999px;
|
||||
|
||||
/* ── Spacing ───────────────────────────────────── */
|
||||
--space-0: 0px;
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
--space-16: 64px;
|
||||
|
||||
/* ── Layout ────────────────────────────────────── */
|
||||
--sidebar-width: 240px;
|
||||
--header-height: 0px;
|
||||
|
||||
/* ── Transitions ───────────────────────────────── */
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-normal: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 500ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-bounce: 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
/* ── Z-index ───────────────────────────────────── */
|
||||
--z-sidebar: 30;
|
||||
--z-header: 40;
|
||||
--z-overlay: 50;
|
||||
--z-modal: 60;
|
||||
--z-toast: 70;
|
||||
}
|
||||
|
||||
/* ── Light Theme ──────────────────────────────────────── */
|
||||
[data-theme="light"] {
|
||||
--surface-base: #f4f6fb;
|
||||
--surface-raised: #ffffff;
|
||||
--surface-overlay: #eeeff4;
|
||||
--surface-container: #dde0e8;
|
||||
--surface-border: rgba(0, 0, 0, 0.06);
|
||||
--surface-hover: rgba(37, 99, 235, 0.05);
|
||||
--surface-glass: rgba(255, 255, 255, 0.72);
|
||||
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #475569;
|
||||
--text-tertiary: #94a3b8;
|
||||
--text-inverse: #ffffff;
|
||||
|
||||
--color-primary: #2563eb;
|
||||
--color-primary-hover: #3b82f6;
|
||||
--color-primary-active: #1d4ed8;
|
||||
--color-primary-muted: rgba(37, 99, 235, 0.1);
|
||||
--gradient-primary: linear-gradient(135deg, #2563eb 0%, #6366f1 100%);
|
||||
--gradient-brand: linear-gradient(135deg, #2563eb 0%, #5865f2 50%, #6366f1 100%);
|
||||
|
||||
--color-success: #10b981;
|
||||
--color-warning: #f59e0b;
|
||||
--color-error: #ef4444;
|
||||
--color-info: #2563eb;
|
||||
--color-destructive: #ef4444;
|
||||
|
||||
--color-ai-flagged: #ef4444;
|
||||
--color-ai-clean: #10b981;
|
||||
--color-ai-warn: #f59e0b;
|
||||
--color-ai-pending: #94a3b8;
|
||||
--color-ai-processing: #2563eb;
|
||||
--color-ai-error: #dc2626;
|
||||
--color-ai-deleted: #6b7280;
|
||||
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
/* ── UI Primitives ────────────────────────────────────── */
|
||||
|
||||
/* ── Button ──────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: 12px 24px;
|
||||
height: 44px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn:active:not(:disabled) { transform: scale(0.97); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--gradient-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.25);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
border: 1.5px solid var(--color-primary);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--color-primary-muted);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-destructive {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
.btn-destructive:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: var(--surface-glass);
|
||||
border: 1px solid var(--surface-border);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.btn-outline:hover:not(:disabled) {
|
||||
background: var(--surface-raised);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: none;
|
||||
height: auto;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
border: none;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-link:hover:not(:disabled) { text-decoration: underline; }
|
||||
|
||||
/* Sizes */
|
||||
.btn-sm { padding: 8px 16px; height: 36px; font-size: 12px; border-radius: var(--radius-md); }
|
||||
.btn-lg { padding: 14px 28px; height: 48px; font-size: 16px; }
|
||||
.btn-icon { width: 44px; height: 44px; padding: 0; display: inline-flex; align-items: center; justify-content: center; font-size: 1.25rem; border-radius: var(--radius-pill); }
|
||||
.btn-icon-sm { width: 36px; height: 36px; padding: 0; display: inline-flex; align-items: center; justify-content: center; font-size: 1rem; border-radius: var(--radius-md); }
|
||||
|
||||
/* ── Card ────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.card-interactive:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
.card-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.card-description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
.card-content { padding: var(--space-6); }
|
||||
.card-footer {
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-top: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
/* ── Badge ───────────────────────────────────────────── */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
letter-spacing: 0.03em;
|
||||
background: var(--surface-container);
|
||||
color: var(--text-secondary);
|
||||
border: none;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.badge-primary { background: var(--color-primary-muted); color: var(--color-primary); }
|
||||
.badge-success { background: rgba(16, 185, 129, 0.12); color: var(--color-success); }
|
||||
.badge-warning { background: rgba(245, 158, 11, 0.12); color: var(--color-warning); }
|
||||
.badge-destructive { background: rgba(239, 68, 68, 0.12); color: var(--color-error); }
|
||||
.badge-outline { background: transparent; border: 1px solid var(--surface-border); color: var(--text-tertiary); }
|
||||
.badge-info { background: var(--color-primary-muted); color: var(--color-primary); }
|
||||
|
||||
/* ── Status Badge ────────────────────────────────────── */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.status-badge::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-badge-flagged { background: rgba(239, 68, 68, 0.12); color: var(--color-ai-flagged); }
|
||||
.status-badge-flagged::before { background: var(--color-ai-flagged); box-shadow: 0 0 8px rgba(239, 68, 68, 0.6); }
|
||||
.status-badge-clean { background: rgba(16, 185, 129, 0.12); color: var(--color-ai-clean); }
|
||||
.status-badge-clean::before { background: var(--color-ai-clean); box-shadow: 0 0 8px rgba(16, 185, 129, 0.4); }
|
||||
.status-badge-warn { background: rgba(245, 158, 11, 0.12); color: var(--color-ai-warn); }
|
||||
.status-badge-warn::before { background: var(--color-ai-warn); box-shadow: 0 0 8px rgba(245, 158, 11, 0.4); }
|
||||
.status-badge-pending { background: rgba(92, 92, 120, 0.12); color: var(--color-ai-pending); }
|
||||
.status-badge-pending::before { background: var(--color-ai-pending); }
|
||||
.status-badge-processing { background: var(--color-primary-muted); color: var(--color-ai-processing); }
|
||||
.status-badge-processing::before { background: var(--color-ai-processing); animation: pulse-dot 1.5s ease-in-out infinite; }
|
||||
.status-badge-error { background: rgba(220, 38, 38, 0.12); color: var(--color-ai-error); }
|
||||
.status-badge-error::before { background: var(--color-ai-error); box-shadow: 0 0 8px rgba(220, 38, 38, 0.4); }
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* ── Input ───────────────────────────────────────────── */
|
||||
.input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
background: var(--surface-container);
|
||||
border: 1.5px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
transition: all var(--transition-fast);
|
||||
outline: none;
|
||||
}
|
||||
.input::placeholder { color: var(--text-tertiary); }
|
||||
.input:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-muted), 0 0 16px rgba(59, 130, 246, 0.08);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
.input[aria-invalid="true"] {
|
||||
border-color: var(--color-error);
|
||||
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
/* ── Select ──────────────────────────────────────────── */
|
||||
.select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--space-3) 2rem var(--space-3) var(--space-3);
|
||||
background: var(--surface-container);
|
||||
border: 1.5px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
outline: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%235c5c78' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
}
|
||||
.select:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-muted);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
/* ── Skeleton ────────────────────────────────────────── */
|
||||
.skeleton {
|
||||
background: linear-gradient(110deg, var(--surface-container) 30%, rgba(59, 130, 246, 0.04) 50%, var(--surface-container) 70%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.8s ease-in-out infinite;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.skeleton-circular { border-radius: 50%; }
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* ── Empty State ─────────────────────────────────────── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-16) var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
.empty-state-title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.empty-state-description {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-tertiary);
|
||||
max-width: 280px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Tabs ────────────────────────────────────────────── */
|
||||
.tabs { display: flex; flex-direction: column; }
|
||||
.tab-list {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
border-bottom: 2px solid var(--surface-border);
|
||||
}
|
||||
.tab-trigger {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
color: var(--text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
.tab-trigger:hover { color: var(--text-primary); }
|
||||
.tab-trigger.active,
|
||||
.tab-trigger[aria-selected="true"] {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
.tab-content {
|
||||
padding-top: var(--space-4);
|
||||
animation: fade-in-up var(--transition-normal) ease-out;
|
||||
}
|
||||
|
||||
/* ── Modal ───────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal);
|
||||
animation: fade-in var(--transition-fast) ease-out;
|
||||
}
|
||||
.modal-content {
|
||||
background: var(--surface-raised);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--surface-border);
|
||||
box-shadow: var(--shadow-lg);
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
overflow: auto;
|
||||
animation: scale-in var(--transition-bounce);
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
.modal-body { padding: var(--space-6); }
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-top: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
/* ── Toast ───────────────────────────────────────────── */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: var(--space-4);
|
||||
right: var(--space-4);
|
||||
z-index: var(--z-toast);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
min-width: 300px;
|
||||
max-width: 420px;
|
||||
pointer-events: auto;
|
||||
animation: slide-in-right var(--transition-bounce);
|
||||
}
|
||||
.toast-success { border-left: 3px solid var(--color-success); }
|
||||
.toast-error { border-left: 3px solid var(--color-error); }
|
||||
.toast-warning { border-left: 3px solid var(--color-warning); }
|
||||
.toast-info { border-left: 3px solid var(--color-primary); }
|
||||
.toast-close {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
.toast-close:hover { color: var(--text-primary); }
|
||||
|
||||
/* ── Animations ──────────────────────────────────────── */
|
||||
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes fade-in-up { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes scale-in { from { opacity: 0; transform: scale(0.92); } to { opacity: 1; transform: scale(1); } }
|
||||
@keyframes slide-in-right { from { opacity: 0; transform: translateX(120%); } to { opacity: 1; transform: translateX(0); } }
|
||||
|
||||
.animate-fade-in { animation: fade-in var(--transition-normal) ease-out; }
|
||||
.animate-fade-in-up { animation: fade-in-up var(--transition-normal) ease-out; }
|
||||
.animate-scale-in { animation: scale-in var(--transition-normal) ease-out; }
|
||||
.animate-slide-in-right { animation: slide-in-right var(--transition-normal) ease-out; }
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
/* ── Utility Classes ──────────────────────────────────── */
|
||||
|
||||
/* Layout */
|
||||
.flex { display: flex; }
|
||||
.inline-flex { display: inline-flex; }
|
||||
.grid { display: grid; }
|
||||
.block { display: block; }
|
||||
.hidden { display: none; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.flex-row { flex-direction: row; }
|
||||
.flex-wrap { flex-wrap: wrap; }
|
||||
.flex-1 { flex: 1 1 0%; }
|
||||
.flex-shrink-0, .shrink-0 { flex-shrink: 0; }
|
||||
.items-center { align-items: center; }
|
||||
.items-start { align-items: flex-start; }
|
||||
.items-end { align-items: flex-end; }
|
||||
.items-baseline { align-items: baseline; }
|
||||
.justify-center { justify-content: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.justify-end { justify-content: flex-end; }
|
||||
.gap-0 { gap: 0; }
|
||||
.gap-1 { gap: var(--space-1); }
|
||||
.gap-1\.5 { gap: 6px; }
|
||||
.gap-2 { gap: var(--space-2); }
|
||||
.gap-3 { gap: var(--space-3); }
|
||||
.gap-4 { gap: var(--space-4); }
|
||||
.gap-6 { gap: var(--space-6); }
|
||||
.gap-8 { gap: var(--space-8); }
|
||||
.grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
.grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
|
||||
/* Width / Height */
|
||||
.w-full { width: 100%; }
|
||||
.w-auto { width: auto; }
|
||||
.h-full { height: 100%; }
|
||||
.h-auto { height: auto; }
|
||||
.min-w-0 { min-width: 0; }
|
||||
.min-h-0 { min-height: 0; }
|
||||
|
||||
/* Sizing helpers */
|
||||
.h-3 { height: 12px; }
|
||||
.h-4 { height: 16px; }
|
||||
.h-5 { height: 20px; }
|
||||
.h-6 { height: 24px; }
|
||||
.h-8 { height: 32px; }
|
||||
.h-10 { height: 40px; }
|
||||
.h-12 { height: 48px; }
|
||||
.h-16 { height: 64px; }
|
||||
.h-28 { height: 112px; }
|
||||
.w-3 { width: 12px; }
|
||||
.w-4 { width: 16px; }
|
||||
.w-5 { width: 20px; }
|
||||
.w-6 { width: 24px; }
|
||||
.w-8 { width: 32px; }
|
||||
.w-10 { width: 40px; }
|
||||
.w-12 { width: 48px; }
|
||||
.w-16 { width: 64px; }
|
||||
.w-48 { width: 192px; }
|
||||
|
||||
/* Position */
|
||||
.relative { position: relative; }
|
||||
.absolute { position: absolute; }
|
||||
.fixed { position: fixed; }
|
||||
.sticky { position: sticky; }
|
||||
.inset-0 { inset: 0; }
|
||||
.top-0 { top: 0; }
|
||||
.right-0 { right: 0; }
|
||||
.bottom-0 { bottom: 0; }
|
||||
.left-0 { left: 0; }
|
||||
|
||||
/* Overflow */
|
||||
.overflow-auto { overflow: auto; }
|
||||
.overflow-hidden { overflow: hidden; }
|
||||
.overflow-y-auto { overflow-y: auto; }
|
||||
.overflow-x-auto { overflow-x: auto; }
|
||||
|
||||
/* Z-index */
|
||||
.z-0 { z-index: 0; }
|
||||
.z-10 { z-index: 10; }
|
||||
.z-50 { z-index: 50; }
|
||||
|
||||
/* Margin */
|
||||
.m-0 { margin: 0; }
|
||||
.mx-auto { margin-left: auto; margin-right: auto; }
|
||||
.ml-auto { margin-left: auto; }
|
||||
.mr-auto { margin-right: auto; }
|
||||
.mt-0 { margin-top: 0; }
|
||||
.mt-1 { margin-top: var(--space-1); }
|
||||
.mt-2 { margin-top: var(--space-2); }
|
||||
.mt-3 { margin-top: var(--space-3); }
|
||||
.mt-4 { margin-top: var(--space-4); }
|
||||
.mt-6 { margin-top: var(--space-6); }
|
||||
.mb-0 { margin-bottom: 0; }
|
||||
.mb-1 { margin-bottom: var(--space-1); }
|
||||
.mb-2 { margin-bottom: var(--space-2); }
|
||||
.mb-3 { margin-bottom: var(--space-3); }
|
||||
.mb-4 { margin-bottom: var(--space-4); }
|
||||
.mb-6 { margin-bottom: var(--space-6); }
|
||||
.ml-0 { margin-left: 0; }
|
||||
.ml-1 { margin-left: var(--space-1); }
|
||||
.ml-2 { margin-left: var(--space-2); }
|
||||
.mr-1 { margin-right: var(--space-1); }
|
||||
.mr-2 { margin-right: var(--space-2); }
|
||||
.mr-1\.5 { margin-right: 6px; }
|
||||
|
||||
/* Padding */
|
||||
.p-0 { padding: 0; }
|
||||
.p-1 { padding: var(--space-1); }
|
||||
.p-2 { padding: var(--space-2); }
|
||||
.p-3 { padding: var(--space-3); }
|
||||
.p-4 { padding: var(--space-4); }
|
||||
.p-5 { padding: var(--space-5); }
|
||||
.p-6 { padding: var(--space-6); }
|
||||
.px-1 { padding-left: var(--space-1); padding-right: var(--space-1); }
|
||||
.px-2 { padding-left: var(--space-2); padding-right: var(--space-2); }
|
||||
.px-3 { padding-left: var(--space-3); padding-right: var(--space-3); }
|
||||
.px-4 { padding-left: var(--space-4); padding-right: var(--space-4); }
|
||||
.px-6 { padding-left: var(--space-6); padding-right: var(--space-6); }
|
||||
.py-0 { padding-top: 0; padding-bottom: 0; }
|
||||
.py-1 { padding-top: var(--space-1); padding-bottom: var(--space-1); }
|
||||
.py-2 { padding-top: var(--space-2); padding-bottom: var(--space-2); }
|
||||
.py-3 { padding-top: var(--space-3); padding-bottom: var(--space-3); }
|
||||
.py-4 { padding-top: var(--space-4); padding-bottom: var(--space-4); }
|
||||
.pt-2 { padding-top: var(--space-2); }
|
||||
.pt-3 { padding-top: var(--space-3); }
|
||||
.pt-4 { padding-top: var(--space-4); }
|
||||
.pb-2 { padding-bottom: var(--space-2); }
|
||||
.pb-3 { padding-bottom: var(--space-3); }
|
||||
.pb-4 { padding-bottom: var(--space-4); }
|
||||
.pl-2 { padding-left: var(--space-2); }
|
||||
.pr-2 { padding-right: var(--space-2); }
|
||||
|
||||
/* Border */
|
||||
.border { border: 1px solid var(--surface-border); }
|
||||
.border-0 { border: none; }
|
||||
.border-t { border-top: 1px solid var(--surface-border); }
|
||||
.border-b { border-bottom: 1px solid var(--surface-border); }
|
||||
.border-l { border-left: 1px solid var(--surface-border); }
|
||||
.border-r { border-right: 1px solid var(--surface-border); }
|
||||
.border-l-3 { border-left-width: 3px; }
|
||||
.border-destructive { border-color: var(--color-error); }
|
||||
.border-border { border-color: var(--surface-border); }
|
||||
.border-primary { border-color: var(--color-primary); }
|
||||
.rounded-sm { border-radius: var(--radius-sm); }
|
||||
.rounded { border-radius: var(--radius-md); }
|
||||
.rounded-md { border-radius: var(--radius-md); }
|
||||
.rounded-lg { border-radius: var(--radius-lg); }
|
||||
.rounded-xl { border-radius: var(--radius-xl); }
|
||||
.rounded-full { border-radius: var(--radius-pill); }
|
||||
|
||||
/* Typography */
|
||||
.text-left { text-align: left; }
|
||||
.text-center { text-align: center; }
|
||||
.text-right { text-align: right; }
|
||||
.whitespace-nowrap { white-space: nowrap; }
|
||||
.whitespace-pre-wrap { white-space: pre-wrap; }
|
||||
.break-words { word-break: break-word; }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.text-xs { font-size: 0.75rem; line-height: 1rem; }
|
||||
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
|
||||
.text-base { font-size: 1rem; line-height: 1.5rem; }
|
||||
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
|
||||
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
|
||||
.text-2xl { font-size: 1.5rem; line-height: 2rem; }
|
||||
.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
|
||||
.font-normal { font-weight: 400; }
|
||||
.font-medium { font-weight: 500; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
.font-bold { font-weight: 700; }
|
||||
.font-extrabold { font-weight: 800; }
|
||||
.text-primary { color: var(--text-primary); }
|
||||
.text-secondary { color: var(--text-secondary); }
|
||||
.text-tertiary { color: var(--text-tertiary); }
|
||||
.text-primary-color { color: var(--color-primary); }
|
||||
.text-error { color: var(--color-error); }
|
||||
.text-success { color: var(--color-success); }
|
||||
.text-warning { color: var(--color-warning); }
|
||||
.text-inverse { color: var(--text-inverse); }
|
||||
|
||||
/* Background */
|
||||
.bg-surface { background: var(--surface-raised); }
|
||||
.bg-overlay { background: var(--surface-overlay); }
|
||||
.bg-base { background: var(--surface-base); }
|
||||
.bg-primary { background: var(--color-primary); }
|
||||
.bg-transparent { background: transparent; }
|
||||
|
||||
/* Opacity / Misc */
|
||||
.opacity-0 { opacity: 0; }
|
||||
.opacity-50 { opacity: 0.5; }
|
||||
.opacity-60 { opacity: 0.6; }
|
||||
.opacity-70 { opacity: 0.7; }
|
||||
.opacity-80 { opacity: 0.8; }
|
||||
.opacity-85 { opacity: 0.85; }
|
||||
.object-contain { object-fit: contain; }
|
||||
.object-cover { object-fit: cover; }
|
||||
.cursor-pointer { cursor: pointer; }
|
||||
.cursor-default { cursor: default; }
|
||||
.cursor-not-allowed { cursor: not-allowed; }
|
||||
.select-none { user-select: none; }
|
||||
.pointer-events-none { pointer-events: none; }
|
||||
.pointer-events-auto { pointer-events: auto; }
|
||||
|
||||
/* Transitions */
|
||||
.transition-all { transition: all var(--transition-fast); }
|
||||
.transition-transform { transition: transform var(--transition-fast); }
|
||||
.transition-opacity { transition: opacity var(--transition-fast); }
|
||||
.transition-colors { transition: background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast); }
|
||||
.transition-shadow { transition: box-shadow var(--transition-fast); }
|
||||
.hover\:scale-105:hover { transform: scale(1.05); }
|
||||
.hover\:opacity-80:hover { opacity: 0.8; }
|
||||
|
||||
/* Animation */
|
||||
.animate-spin { animation: spin 1s linear infinite; }
|
||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.grid-cols-2 { grid-template-columns: 1fr; }
|
||||
.grid-cols-3 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ── Extra Utilities (component-used aliases) ────────── */
|
||||
.active { background: var(--gradient-primary); color: white; }
|
||||
.is-active { background: var(--gradient-primary); color: white; }
|
||||
.is-deleted { opacity: 0.6; }
|
||||
.is-connecting { animation: pulse-connecting 1s ease-in-out infinite; }
|
||||
.is-scroll { overflow-x: auto; flex-wrap: nowrap; }
|
||||
.separated > * + * { margin-top: 0.625rem; padding-top: 0.625rem; border-top: 1px solid var(--surface-border); }
|
||||
.tall { height: 112px; width: 64px; }
|
||||
.typing { animation: pulse-dot 1.5s ease-in-out infinite; }
|
||||
.mascot { font-variant-numeric: tabular-nums; }
|
||||
|
||||
.ml-0\.5 { margin-left: 2px; }
|
||||
.space-y-2 > * + * { margin-top: var(--space-2); }
|
||||
.tracking-tight { letter-spacing: -0.025em; }
|
||||
.text-foreground { color: var(--text-primary); }
|
||||
.text-muted-foreground { color: var(--text-tertiary); }
|
||||
.text-destructive { color: var(--color-error); }
|
||||
.text-\[10px\] { font-size: 10px; }
|
||||
.h-3\.5 { height: 14px; }
|
||||
.w-3\.5 { width: 14px; }
|
||||
|
||||
.bg-background { background: var(--surface-base); }
|
||||
.bg-card { background: var(--surface-raised); }
|
||||
.border-input { border-color: var(--surface-border); }
|
||||
|
||||
.head-left { display: flex; align-items: center; gap: var(--space-3); }
|
||||
.head-right { display: flex; align-items: center; gap: var(--space-4); margin-left: auto; }
|
||||
.head-status { display: flex; align-items: center; gap: var(--space-1); font-size: 0.75rem; color: var(--text-secondary); }
|
||||
.head-status-dot { width: 8px; height: 8px; border-radius: 50%; transition: all var(--transition-fast); }
|
||||
|
||||
.card-bordered { border: 1px solid var(--surface-border); }
|
||||
.card-elevated { box-shadow: var(--shadow-md); }
|
||||
|
||||
.empty-state-icon { font-size: 3rem; margin-bottom: var(--space-4); opacity: 0.5; }
|
||||
|
||||
.input-error { border-color: var(--color-error); }
|
||||
.input-soft { background: var(--surface-container); border: 1px solid transparent; }
|
||||
|
||||
.theme-toggle { width: 44px; height: 44px; border: 1px solid var(--surface-border); border-radius: var(--radius-md); background: var(--surface-overlay); color: var(--text-tertiary); cursor: pointer; font-size: 1.25rem; display: flex; align-items: center; justify-content: center; transition: all var(--transition-fast); }
|
||||
.theme-toggle:hover { color: var(--color-primary); border-color: var(--color-primary); }
|
||||
|
||||
.mascot-controls { display: flex; align-items: center; gap: var(--space-2); }
|
||||
.mascot-inline { display: flex; align-items: center; gap: var(--space-1); }
|
||||
|
||||
.placeholder-muted-foreground::placeholder { color: var(--text-tertiary); }
|
||||
|
||||
.md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
@media (max-width: 768px) { .md\:grid-cols-2 { grid-template-columns: 1fr; } }
|
||||
|
||||
.bg-destructive\/15 { background: rgba(239, 68, 68, 0.15); }
|
||||
.border-border\/50 { border-color: rgba(255, 255, 255, 0.03); }
|
||||
[data-theme="light"] .border-border\/50 { border-color: rgba(0, 0, 0, 0.03); }
|
||||
@@ -1,34 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub enum BadgeVariant {
|
||||
#[default]
|
||||
Default,
|
||||
Primary,
|
||||
Success,
|
||||
Warning,
|
||||
Destructive,
|
||||
Outline,
|
||||
Info,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Badge(#[prop(optional)] variant: BadgeVariant, children: Children) -> impl IntoView {
|
||||
let variant_class = match variant {
|
||||
BadgeVariant::Default => "",
|
||||
BadgeVariant::Primary => "badge-primary",
|
||||
BadgeVariant::Success => "badge-success",
|
||||
BadgeVariant::Warning => "badge-warning",
|
||||
BadgeVariant::Destructive => "badge-destructive",
|
||||
BadgeVariant::Outline => "badge-outline",
|
||||
BadgeVariant::Info => "badge-info",
|
||||
};
|
||||
|
||||
let combined = format!("badge {}", variant_class);
|
||||
|
||||
view! {
|
||||
<span class=combined>
|
||||
{children()}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub enum ButtonVariant {
|
||||
#[default]
|
||||
Primary,
|
||||
Secondary,
|
||||
Tertiary,
|
||||
Destructive,
|
||||
Outline,
|
||||
Ghost,
|
||||
Link,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub enum ButtonSize {
|
||||
#[default]
|
||||
Default,
|
||||
Sm,
|
||||
Lg,
|
||||
Icon,
|
||||
IconSm,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Button(
|
||||
#[prop(optional)] variant: ButtonVariant,
|
||||
#[prop(optional)] size: ButtonSize,
|
||||
#[prop(optional)] disabled: bool,
|
||||
#[prop(optional)] class: &'static str,
|
||||
#[prop(optional)] on_click: Option<Box<dyn Fn(leptos::ev::MouseEvent)>>,
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
let variant_class = match variant {
|
||||
ButtonVariant::Primary => "btn-primary",
|
||||
ButtonVariant::Secondary => "btn-secondary",
|
||||
ButtonVariant::Tertiary => "btn-tertiary",
|
||||
ButtonVariant::Destructive => "btn-destructive",
|
||||
ButtonVariant::Outline => "btn-outline",
|
||||
ButtonVariant::Ghost => "btn-ghost",
|
||||
ButtonVariant::Link => "btn-link",
|
||||
};
|
||||
let size_class = match size {
|
||||
ButtonSize::Default => "",
|
||||
ButtonSize::Sm => "btn-sm",
|
||||
ButtonSize::Lg => "btn-lg",
|
||||
ButtonSize::Icon => "btn-icon",
|
||||
ButtonSize::IconSm => "btn-icon-sm",
|
||||
};
|
||||
|
||||
let combined = format!("btn {} {} {}", variant_class, size_class, class);
|
||||
|
||||
view! {
|
||||
<button
|
||||
class=combined
|
||||
disabled=disabled
|
||||
on:click=move |ev| { if let Some(ref cb) = on_click { cb(ev); } }
|
||||
>
|
||||
{children()}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
|
||||
#[component]
|
||||
pub fn Card(
|
||||
#[prop(optional)] elevated: bool,
|
||||
#[prop(optional)] bordered: bool,
|
||||
#[prop(optional)] class: &'static str,
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
let combined = format!("card {}", class);
|
||||
|
||||
view! {
|
||||
<div
|
||||
class=combined
|
||||
class:card-elevated=elevated
|
||||
class:card-bordered=bordered
|
||||
>
|
||||
{children()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn CardHeader(children: Children) -> impl IntoView {
|
||||
view! { <div class="card-header">{children()}</div> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn CardTitle(children: Children) -> impl IntoView {
|
||||
view! { <h3 class="card-title">{children()}</h3> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn CardDescription(children: Children) -> impl IntoView {
|
||||
view! { <p class="card-description">{children()}</p> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn CardContent(children: Children) -> impl IntoView {
|
||||
view! { <div class="card-content">{children()}</div> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn CardFooter(children: Children) -> impl IntoView {
|
||||
view! { <div class="card-footer">{children()}</div> }
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// services/frontend-leptos/frontend/src/ui/empty_state.rs
|
||||
use leptos::prelude::*;
|
||||
|
||||
|
||||
#[component]
|
||||
pub fn EmptyState(
|
||||
#[prop(optional)] icon: Option<AnyView>,
|
||||
title: &'static str,
|
||||
#[prop(optional)] description: Option<&'static str>,
|
||||
#[prop(optional)] children: Option<Children>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="empty-state">
|
||||
{icon.map(|i| view! { <div class="empty-state-icon">{i}</div> })}
|
||||
<div class="empty-state-title">{title}</div>
|
||||
{description.map(|d| view! { <p class="empty-state-description">{d}</p> })}
|
||||
{children.map(|c| c())}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// services/frontend-leptos/frontend/src/ui/input.rs
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn Input(
|
||||
#[prop(optional)] input_type: &'static str,
|
||||
#[prop(optional)] placeholder: &'static str,
|
||||
#[prop(optional)] value: RwSignal<String>,
|
||||
#[prop(optional)] soft: bool,
|
||||
#[prop(optional)] error: bool,
|
||||
#[prop(optional)] class: &'static str,
|
||||
#[prop(optional)] on_input: Option<Box<dyn Fn(String)>>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<input
|
||||
type=input_type
|
||||
class={if !class.is_empty() { format!("input {}", class) } else { "input".to_string() }}
|
||||
class:input-soft=soft
|
||||
class:input-error=error
|
||||
placeholder=placeholder
|
||||
prop:value=move || value.get()
|
||||
on:input=move |ev| {
|
||||
let val = event_target_value(&ev);
|
||||
value.set(val.clone());
|
||||
if let Some(ref cb) = on_input { cb(val); }
|
||||
}
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TextArea(
|
||||
#[prop(optional)] placeholder: &'static str,
|
||||
#[prop(optional)] value: RwSignal<String>,
|
||||
#[prop(optional)] rows: u32,
|
||||
#[prop(optional)] class: &'static str,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<textarea
|
||||
class={if !class.is_empty() { format!("input {}", class) } else { "input".to_string() }}
|
||||
placeholder=placeholder
|
||||
prop:value=move || value.get()
|
||||
on:input=move |ev| value.set(event_target_value(&ev))
|
||||
rows=rows
|
||||
></textarea>
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// services/frontend-leptos/frontend/src/ui/mod.rs
|
||||
pub mod badge;
|
||||
pub mod button;
|
||||
pub mod card;
|
||||
pub mod empty_state;
|
||||
pub mod input;
|
||||
pub mod modal;
|
||||
pub mod scroll_area;
|
||||
pub mod select;
|
||||
pub mod skeleton;
|
||||
pub mod status_badge;
|
||||
pub mod tabs;
|
||||
pub mod toast;
|
||||
@@ -1,47 +0,0 @@
|
||||
// services/frontend-leptos/frontend/src/ui/modal.rs
|
||||
use leptos::prelude::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
#[component]
|
||||
pub fn Modal(
|
||||
is_open: RwSignal<bool>,
|
||||
#[prop(optional)] title: Option<&'static str>,
|
||||
#[prop(optional)] on_close: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
let oc1 = on_close.clone();
|
||||
let oc2 = on_close;
|
||||
|
||||
view! {
|
||||
<div
|
||||
class="modal-overlay"
|
||||
style=move || {
|
||||
if is_open.get() {
|
||||
String::new()
|
||||
} else {
|
||||
"display: none;".to_string()
|
||||
}
|
||||
}
|
||||
on:click=move |_| {
|
||||
is_open.set(false);
|
||||
if let Some(ref cb) = oc1 { cb(); }
|
||||
}
|
||||
>
|
||||
<div class="modal-content" on:click=|ev| ev.stop_propagation()>
|
||||
{title.map(|t| view! {
|
||||
<div class="modal-header">
|
||||
<h3>{t}</h3>
|
||||
<button class="btn btn-ghost btn-icon-sm" on:click=move |_| {
|
||||
is_open.set(false);
|
||||
if let Some(ref cb) = oc2 { cb(); }
|
||||
}>"×"</button>
|
||||
</div>
|
||||
})}
|
||||
<div class="modal-body">
|
||||
{children()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
// services/frontend-leptos/frontend/src/ui/scroll_area.rs
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn ScrollArea(
|
||||
#[prop(optional)] class: &'static str,
|
||||
#[prop(optional)] style: &'static str,
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class={if !class.is_empty() { format!("scroll-area {}", class) } else { "scroll-area".to_string() }} style=style>
|
||||
{children()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
// services/frontend-leptos/frontend/src/ui/select.rs
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// Simple select — values and labels are the same
|
||||
/// For options with different value/label, use `SelectOptions`
|
||||
#[component]
|
||||
pub fn Select(
|
||||
#[prop(optional)] value: RwSignal<String>,
|
||||
options: Vec<(&'static str, &'static str)>, // (value, label)
|
||||
#[prop(optional)] placeholder: &'static str,
|
||||
#[prop(optional)] class: &'static str,
|
||||
#[prop(optional)] on_change: Option<Box<dyn Fn(String)>>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<select
|
||||
class={if !class.is_empty() { format!("select {}", class) } else { "select".to_string() }}
|
||||
prop:value=move || value.get()
|
||||
on:change=move |ev| {
|
||||
let val = event_target_value(&ev);
|
||||
value.set(val.clone());
|
||||
if let Some(ref cb) = on_change { cb(val); }
|
||||
}
|
||||
>
|
||||
<option value="" disabled=placeholder.len() > 0>{placeholder}</option>
|
||||
{options.into_iter().map(|(val, label)| view! {
|
||||
<option value=val selected=move || value.get() == val>{label}</option>
|
||||
}).collect::<Vec<_>>()}
|
||||
</select>
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
// services/frontend-leptos/frontend/src/ui/skeleton.rs
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub enum SkeletonShape {
|
||||
#[default]
|
||||
Rounded,
|
||||
Circular,
|
||||
Rectangular,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Skeleton(
|
||||
#[prop(optional)] width: &'static str,
|
||||
#[prop(optional)] height: &'static str,
|
||||
#[prop(optional)] shape: SkeletonShape,
|
||||
) -> impl IntoView {
|
||||
let shape_class = match shape {
|
||||
SkeletonShape::Rounded => "",
|
||||
SkeletonShape::Circular => "skeleton-circular",
|
||||
SkeletonShape::Rectangular => "skeleton-rectangular",
|
||||
};
|
||||
let combined = format!("skeleton {}", shape_class);
|
||||
view! {
|
||||
<div
|
||||
class=combined
|
||||
style=format!("width: {}; height: {};", width, height)
|
||||
></div>
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// services/frontend-leptos/frontend/src/ui/status_badge.rs
|
||||
use leptos::prelude::*;
|
||||
use shared_types::message::AiStatus;
|
||||
|
||||
#[component]
|
||||
pub fn StatusBadge(status: AiStatus) -> impl IntoView {
|
||||
let (class, label) = match status {
|
||||
AiStatus::Flagged => ("status-badge-flagged", "Flagged"),
|
||||
AiStatus::Clean => ("status-badge-clean", "Clean"),
|
||||
AiStatus::Warn => ("status-badge-warn", "Warned"),
|
||||
AiStatus::Pending => ("status-badge-pending", "Pending"),
|
||||
AiStatus::Processing => ("status-badge-processing", "Processing"),
|
||||
AiStatus::Error => ("status-badge-error", "Error"),
|
||||
};
|
||||
let combined = format!("status-badge {}", class);
|
||||
view! {
|
||||
<span class=combined>
|
||||
{label}
|
||||
</span>
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
// services/frontend-leptos/frontend/src/ui/tabs.rs
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn Tabs(
|
||||
active: RwSignal<String>,
|
||||
#[prop(optional)] class: &'static str,
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
let _ = active;
|
||||
view! {
|
||||
<div class={if !class.is_empty() { format!("tabs {}", class) } else { "tabs".to_string() }}>
|
||||
{children()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TabList(#[prop(optional)] class: &'static str, children: Children) -> impl IntoView {
|
||||
view! {
|
||||
<div class={if !class.is_empty() { format!("tab-list {}", class) } else { "tab-list".to_string() }} role="tablist">
|
||||
{children()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TabTrigger(value: String, active: RwSignal<String>, children: Children) -> impl IntoView {
|
||||
let v1 = value.clone();
|
||||
let v2 = value.clone();
|
||||
view! {
|
||||
<button
|
||||
class="tab-trigger"
|
||||
class:active=move || active.get() == v1
|
||||
role="tab"
|
||||
aria-selected=move || if active.get() == v2 { "true" } else { "false" }
|
||||
on:click=move |_| active.set(value.clone())
|
||||
>
|
||||
{children()}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TabContent(value: String, active: RwSignal<String>, children: Children) -> impl IntoView {
|
||||
let is_selected = move || active.get() == value;
|
||||
view! {
|
||||
<div
|
||||
class="tab-content"
|
||||
role="tabpanel"
|
||||
style:display=move || if is_selected() { "block" } else { "none" }
|
||||
>
|
||||
{children()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
// services/frontend-leptos/frontend/src/ui/toast.rs
|
||||
use leptos::prelude::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::fmt;
|
||||
use crate::{log_info, make_logger};
|
||||
|
||||
make_logger!();
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum ToastType {
|
||||
Info,
|
||||
Success,
|
||||
Error,
|
||||
Warning,
|
||||
}
|
||||
|
||||
impl fmt::Display for ToastType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ToastType::Info => write!(f, "info"),
|
||||
ToastType::Success => write!(f, "success"),
|
||||
ToastType::Error => write!(f, "error"),
|
||||
ToastType::Warning => write!(f, "warning"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ToastMessage {
|
||||
pub id: u64,
|
||||
pub message: String,
|
||||
pub toast_type: ToastType,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ToastContext {
|
||||
pub toasts: RwSignal<Vec<ToastMessage>>,
|
||||
next_id: Arc<Mutex<u64>>,
|
||||
}
|
||||
|
||||
impl Default for ToastContext {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToastContext {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
toasts: RwSignal::new(vec![]),
|
||||
next_id: Arc::new(Mutex::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(&self, message: &str, toast_type: ToastType) {
|
||||
log_info!("Toast: {} ({})", message, toast_type);
|
||||
let id = {
|
||||
let mut n = self.next_id.lock().unwrap();
|
||||
*n += 1;
|
||||
*n
|
||||
};
|
||||
let msg = ToastMessage {
|
||||
id,
|
||||
message: message.to_string(),
|
||||
toast_type,
|
||||
};
|
||||
self.toasts.update(|t| t.push(msg));
|
||||
|
||||
// Auto-dismiss after 4 seconds
|
||||
let toasts = self.toasts;
|
||||
let _ = leptos::prelude::set_timeout(
|
||||
move || {
|
||||
toasts.update(|t| t.retain(|m| m.id != id));
|
||||
},
|
||||
std::time::Duration::from_secs(4),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ToastProvider(children: Children) -> impl IntoView {
|
||||
let ctx = ToastContext::new();
|
||||
provide_context(ctx.clone());
|
||||
|
||||
view! {
|
||||
{children()}
|
||||
<div class="toast-container">
|
||||
{move || ctx.toasts.get().into_iter().map(|msg| {
|
||||
let type_class = match msg.toast_type {
|
||||
ToastType::Info => "toast-info",
|
||||
ToastType::Success => "toast-success",
|
||||
ToastType::Error => "toast-error",
|
||||
ToastType::Warning => "toast-warning",
|
||||
};
|
||||
let toasts = ctx.toasts;
|
||||
view! {
|
||||
<div class={format!("toast {}", type_class)}>
|
||||
<span>{msg.message}</span>
|
||||
<button class="toast-close" on:click=move |_| {
|
||||
toasts.update(|t| t.retain(|m| m.id != msg.id));
|
||||
}>"×"</button>
|
||||
</div>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user