From 6f1448dc4ae62965fd1608cdbfb00501d51131a3 Mon Sep 17 00:00:00 2001
From: 27942 <1313123@342>
Date: Tue, 27 Jan 2026 13:41:31 +0800
Subject: [PATCH] haha
---
.gitignore | 107 +
.gitkeep | 0
.prettierignore | 35 +
.prettierrc | 15 +
client/index.html | 24 +
client/public/.gitkeep | 0
client/public/__manus__/debug-collector.js | 821 ++
client/src/App.tsx | 46 +
client/src/_core/hooks/useAuth.ts | 84 +
client/src/components/AIChatBox.tsx | 335 +
client/src/components/AdvancedFilter.tsx | 164 +
client/src/components/DashboardLayout.tsx | 264 +
.../components/DashboardLayoutSkeleton.tsx | 46 +
client/src/components/ErrorBoundary.tsx | 62 +
client/src/components/ManusDialog.tsx | 89 +
client/src/components/Map.tsx | 155 +
client/src/components/ui/accordion.tsx | 64 +
client/src/components/ui/alert-dialog.tsx | 155 +
client/src/components/ui/alert.tsx | 66 +
client/src/components/ui/aspect-ratio.tsx | 9 +
client/src/components/ui/avatar.tsx | 51 +
client/src/components/ui/badge.tsx | 46 +
client/src/components/ui/breadcrumb.tsx | 109 +
client/src/components/ui/button-group.tsx | 83 +
client/src/components/ui/button.tsx | 60 +
client/src/components/ui/calendar.tsx | 211 +
client/src/components/ui/card.tsx | 92 +
client/src/components/ui/carousel.tsx | 239 +
client/src/components/ui/chart.tsx | 355 +
client/src/components/ui/checkbox.tsx | 30 +
client/src/components/ui/collapsible.tsx | 31 +
client/src/components/ui/command.tsx | 184 +
client/src/components/ui/context-menu.tsx | 250 +
client/src/components/ui/dialog.tsx | 209 +
client/src/components/ui/drawer.tsx | 133 +
client/src/components/ui/dropdown-menu.tsx | 255 +
client/src/components/ui/empty.tsx | 104 +
client/src/components/ui/field.tsx | 242 +
client/src/components/ui/form.tsx | 168 +
client/src/components/ui/hover-card.tsx | 42 +
client/src/components/ui/input-group.tsx | 168 +
client/src/components/ui/input-otp.tsx | 75 +
client/src/components/ui/input.tsx | 70 +
client/src/components/ui/item.tsx | 193 +
client/src/components/ui/kbd.tsx | 28 +
client/src/components/ui/label.tsx | 22 +
client/src/components/ui/menubar.tsx | 274 +
client/src/components/ui/navigation-menu.tsx | 168 +
client/src/components/ui/pagination.tsx | 127 +
client/src/components/ui/popover.tsx | 46 +
client/src/components/ui/progress.tsx | 29 +
client/src/components/ui/radio-group.tsx | 43 +
client/src/components/ui/resizable.tsx | 54 +
client/src/components/ui/scroll-area.tsx | 56 +
client/src/components/ui/select.tsx | 185 +
client/src/components/ui/separator.tsx | 26 +
client/src/components/ui/sheet.tsx | 139 +
client/src/components/ui/sidebar.tsx | 734 ++
client/src/components/ui/skeleton.tsx | 13 +
client/src/components/ui/slider.tsx | 61 +
client/src/components/ui/sonner.tsx | 23 +
client/src/components/ui/spinner.tsx | 16 +
client/src/components/ui/switch.tsx | 29 +
client/src/components/ui/table.tsx | 114 +
client/src/components/ui/tabs.tsx | 64 +
client/src/components/ui/textarea.tsx | 67 +
client/src/components/ui/toggle-group.tsx | 73 +
client/src/components/ui/toggle.tsx | 45 +
client/src/components/ui/tooltip.tsx | 59 +
client/src/const.ts | 17 +
client/src/contexts/ThemeContext.tsx | 64 +
client/src/hooks/useComposition.ts | 81 +
client/src/hooks/useMobile.tsx | 21 +
client/src/hooks/usePersistFn.ts | 20 +
client/src/index.css | 239 +
client/src/lib/trpc.ts | 4 +
client/src/lib/utils.ts | 6 +
client/src/main.tsx | 61 +
client/src/pages/AdminDashboard.tsx | 473 +
client/src/pages/AdminProducts.tsx | 292 +
client/src/pages/AdminTags.tsx | 428 +
client/src/pages/AdminWebsites.tsx | 364 +
client/src/pages/Bounties.tsx | 381 +
client/src/pages/BountyDetail.tsx | 611 ++
client/src/pages/ComponentShowcase.tsx | 1437 +++
client/src/pages/Dashboard.tsx | 409 +
client/src/pages/Favorites.tsx | 527 +
client/src/pages/Home.tsx | 229 +
client/src/pages/NotFound.tsx | 52 +
client/src/pages/ProductComparison.tsx | 546 +
client/src/pages/Products.tsx | 882 ++
components.json | 19 +
drizzle.config.ts | 15 +
drizzle/0000_fearless_carlie_cooper.sql | 13 +
drizzle/0001_salty_scream.sql | 112 +
drizzle/0002_exotic_cloak.sql | 8 +
drizzle/0003_messy_rachel_grey.sql | 17 +
drizzle/0004_whole_mandrill.sql | 23 +
drizzle/0005_yielding_king_bedlam.sql | 23 +
drizzle/0006_shiny_cardiac.sql | 8 +
drizzle/meta/0001_snapshot.json | 839 ++
drizzle/meta/0002_snapshot.json | 892 ++
drizzle/meta/0003_snapshot.json | 999 ++
drizzle/meta/0004_snapshot.json | 1150 +++
drizzle/meta/0005_snapshot.json | 1302 +++
drizzle/meta/0006_snapshot.json | 1355 +++
drizzle/meta/_journal.json | 55 +
drizzle/migrations/.gitkeep | 0
drizzle/relations.ts | 1 +
drizzle/schema.ts | 295 +
package.json | 117 +
patches/wouter@3.7.1.patch | 28 +
pnpm-lock.yaml | 9060 +++++++++++++++++
server/_core/context.ts | 28 +
server/_core/cookies.ts | 48 +
server/_core/dataApi.ts | 64 +
server/_core/env.ts | 12 +
server/_core/imageGeneration.ts | 92 +
server/_core/index.ts | 68 +
server/_core/llm.ts | 332 +
server/_core/map.ts | 319 +
server/_core/notification.ts | 114 +
server/_core/oauth.ts | 53 +
server/_core/sdk.ts | 304 +
server/_core/systemRouter.ts | 29 +
server/_core/trpc.ts | 45 +
server/_core/types/cookie.d.ts | 6 +
server/_core/types/manusTypes.ts | 69 +
server/_core/vite.ts | 67 +
server/_core/voiceTranscription.ts | 284 +
server/admin.test.ts | 114 +
server/auth.logout.test.ts | 62 +
server/bounty.test.ts | 189 +
server/db.ts | 1728 ++++
server/filter.test.ts | 169 +
server/hotTags.test.ts | 141 +
server/personalizedTags.test.ts | 113 +
server/routers.ts | 1315 +++
server/stats.test.ts | 93 +
server/storage.ts | 102 +
server/stripe.ts | 140 +
server/tagStats.test.ts | 152 +
server/webhooks.ts | 115 +
server/websiteTags.test.ts | 169 +
shared/_core/errors.ts | 19 +
shared/const.ts | 5 +
shared/types.ts | 7 +
todo.md | 300 +
tsconfig.json | 23 +
vite.config.ts | 187 +
vitest.config.ts | 19 +
151 files changed, 39007 insertions(+)
create mode 100644 .gitignore
create mode 100644 .gitkeep
create mode 100644 .prettierignore
create mode 100644 .prettierrc
create mode 100644 client/index.html
create mode 100644 client/public/.gitkeep
create mode 100644 client/public/__manus__/debug-collector.js
create mode 100644 client/src/App.tsx
create mode 100644 client/src/_core/hooks/useAuth.ts
create mode 100644 client/src/components/AIChatBox.tsx
create mode 100644 client/src/components/AdvancedFilter.tsx
create mode 100644 client/src/components/DashboardLayout.tsx
create mode 100644 client/src/components/DashboardLayoutSkeleton.tsx
create mode 100644 client/src/components/ErrorBoundary.tsx
create mode 100644 client/src/components/ManusDialog.tsx
create mode 100644 client/src/components/Map.tsx
create mode 100644 client/src/components/ui/accordion.tsx
create mode 100644 client/src/components/ui/alert-dialog.tsx
create mode 100644 client/src/components/ui/alert.tsx
create mode 100644 client/src/components/ui/aspect-ratio.tsx
create mode 100644 client/src/components/ui/avatar.tsx
create mode 100644 client/src/components/ui/badge.tsx
create mode 100644 client/src/components/ui/breadcrumb.tsx
create mode 100644 client/src/components/ui/button-group.tsx
create mode 100644 client/src/components/ui/button.tsx
create mode 100644 client/src/components/ui/calendar.tsx
create mode 100644 client/src/components/ui/card.tsx
create mode 100644 client/src/components/ui/carousel.tsx
create mode 100644 client/src/components/ui/chart.tsx
create mode 100644 client/src/components/ui/checkbox.tsx
create mode 100644 client/src/components/ui/collapsible.tsx
create mode 100644 client/src/components/ui/command.tsx
create mode 100644 client/src/components/ui/context-menu.tsx
create mode 100644 client/src/components/ui/dialog.tsx
create mode 100644 client/src/components/ui/drawer.tsx
create mode 100644 client/src/components/ui/dropdown-menu.tsx
create mode 100644 client/src/components/ui/empty.tsx
create mode 100644 client/src/components/ui/field.tsx
create mode 100644 client/src/components/ui/form.tsx
create mode 100644 client/src/components/ui/hover-card.tsx
create mode 100644 client/src/components/ui/input-group.tsx
create mode 100644 client/src/components/ui/input-otp.tsx
create mode 100644 client/src/components/ui/input.tsx
create mode 100644 client/src/components/ui/item.tsx
create mode 100644 client/src/components/ui/kbd.tsx
create mode 100644 client/src/components/ui/label.tsx
create mode 100644 client/src/components/ui/menubar.tsx
create mode 100644 client/src/components/ui/navigation-menu.tsx
create mode 100644 client/src/components/ui/pagination.tsx
create mode 100644 client/src/components/ui/popover.tsx
create mode 100644 client/src/components/ui/progress.tsx
create mode 100644 client/src/components/ui/radio-group.tsx
create mode 100644 client/src/components/ui/resizable.tsx
create mode 100644 client/src/components/ui/scroll-area.tsx
create mode 100644 client/src/components/ui/select.tsx
create mode 100644 client/src/components/ui/separator.tsx
create mode 100644 client/src/components/ui/sheet.tsx
create mode 100644 client/src/components/ui/sidebar.tsx
create mode 100644 client/src/components/ui/skeleton.tsx
create mode 100644 client/src/components/ui/slider.tsx
create mode 100644 client/src/components/ui/sonner.tsx
create mode 100644 client/src/components/ui/spinner.tsx
create mode 100644 client/src/components/ui/switch.tsx
create mode 100644 client/src/components/ui/table.tsx
create mode 100644 client/src/components/ui/tabs.tsx
create mode 100644 client/src/components/ui/textarea.tsx
create mode 100644 client/src/components/ui/toggle-group.tsx
create mode 100644 client/src/components/ui/toggle.tsx
create mode 100644 client/src/components/ui/tooltip.tsx
create mode 100644 client/src/const.ts
create mode 100644 client/src/contexts/ThemeContext.tsx
create mode 100644 client/src/hooks/useComposition.ts
create mode 100644 client/src/hooks/useMobile.tsx
create mode 100644 client/src/hooks/usePersistFn.ts
create mode 100644 client/src/index.css
create mode 100644 client/src/lib/trpc.ts
create mode 100644 client/src/lib/utils.ts
create mode 100644 client/src/main.tsx
create mode 100644 client/src/pages/AdminDashboard.tsx
create mode 100644 client/src/pages/AdminProducts.tsx
create mode 100644 client/src/pages/AdminTags.tsx
create mode 100644 client/src/pages/AdminWebsites.tsx
create mode 100644 client/src/pages/Bounties.tsx
create mode 100644 client/src/pages/BountyDetail.tsx
create mode 100644 client/src/pages/ComponentShowcase.tsx
create mode 100644 client/src/pages/Dashboard.tsx
create mode 100644 client/src/pages/Favorites.tsx
create mode 100644 client/src/pages/Home.tsx
create mode 100644 client/src/pages/NotFound.tsx
create mode 100644 client/src/pages/ProductComparison.tsx
create mode 100644 client/src/pages/Products.tsx
create mode 100644 components.json
create mode 100644 drizzle.config.ts
create mode 100644 drizzle/0000_fearless_carlie_cooper.sql
create mode 100644 drizzle/0001_salty_scream.sql
create mode 100644 drizzle/0002_exotic_cloak.sql
create mode 100644 drizzle/0003_messy_rachel_grey.sql
create mode 100644 drizzle/0004_whole_mandrill.sql
create mode 100644 drizzle/0005_yielding_king_bedlam.sql
create mode 100644 drizzle/0006_shiny_cardiac.sql
create mode 100644 drizzle/meta/0001_snapshot.json
create mode 100644 drizzle/meta/0002_snapshot.json
create mode 100644 drizzle/meta/0003_snapshot.json
create mode 100644 drizzle/meta/0004_snapshot.json
create mode 100644 drizzle/meta/0005_snapshot.json
create mode 100644 drizzle/meta/0006_snapshot.json
create mode 100644 drizzle/meta/_journal.json
create mode 100644 drizzle/migrations/.gitkeep
create mode 100644 drizzle/relations.ts
create mode 100644 drizzle/schema.ts
create mode 100644 package.json
create mode 100644 patches/wouter@3.7.1.patch
create mode 100644 pnpm-lock.yaml
create mode 100644 server/_core/context.ts
create mode 100644 server/_core/cookies.ts
create mode 100644 server/_core/dataApi.ts
create mode 100644 server/_core/env.ts
create mode 100644 server/_core/imageGeneration.ts
create mode 100644 server/_core/index.ts
create mode 100644 server/_core/llm.ts
create mode 100644 server/_core/map.ts
create mode 100644 server/_core/notification.ts
create mode 100644 server/_core/oauth.ts
create mode 100644 server/_core/sdk.ts
create mode 100644 server/_core/systemRouter.ts
create mode 100644 server/_core/trpc.ts
create mode 100644 server/_core/types/cookie.d.ts
create mode 100644 server/_core/types/manusTypes.ts
create mode 100644 server/_core/vite.ts
create mode 100644 server/_core/voiceTranscription.ts
create mode 100644 server/admin.test.ts
create mode 100644 server/auth.logout.test.ts
create mode 100644 server/bounty.test.ts
create mode 100644 server/db.ts
create mode 100644 server/filter.test.ts
create mode 100644 server/hotTags.test.ts
create mode 100644 server/personalizedTags.test.ts
create mode 100644 server/routers.ts
create mode 100644 server/stats.test.ts
create mode 100644 server/storage.ts
create mode 100644 server/stripe.ts
create mode 100644 server/tagStats.test.ts
create mode 100644 server/webhooks.ts
create mode 100644 server/websiteTags.test.ts
create mode 100644 shared/_core/errors.ts
create mode 100644 shared/const.ts
create mode 100644 shared/types.ts
create mode 100644 todo.md
create mode 100644 tsconfig.json
create mode 100644 vite.config.ts
create mode 100644 vitest.config.ts
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c1dbd8b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,107 @@
+# Dependencies
+**/node_modules
+.pnpm-store/
+
+# Build outputs
+dist/
+build/
+*.dist
+
+# Environment variables
+.env
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+# IDE and editor files
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS generated files
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
+
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+*.bak
+
+# Coverage directory used by tools like istanbul
+coverage/
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Dependency directories
+jspm_packages/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+.parcel-cache
+
+# Next.js build output
+.next
+
+# Nuxt.js build / generate output
+.nuxt
+
+# Gatsby files
+.cache/
+
+# Storybook build outputs
+.out
+.storybook-out
+
+# Temporary folders
+tmp/
+temp/
+
+# Database
+*.db
+*.sqlite
+*.sqlite3
diff --git a/.gitkeep b/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..7284259
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,35 @@
+# Dependencies
+node_modules/
+.pnpm-store/
+
+# Build outputs
+dist/
+build/
+*.dist
+
+# Generated files
+*.tsbuildinfo
+coverage/
+
+# Package files
+package-lock.json
+pnpm-lock.yaml
+
+# Database
+*.db
+*.sqlite
+*.sqlite3
+
+# Logs
+*.log
+
+# Environment files
+.env*
+
+# IDE files
+.vscode/
+.idea/
+
+# OS files
+.DS_Store
+Thumbs.db
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..67c0bc8
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,15 @@
+{
+ "semi": true,
+ "trailingComma": "es5",
+ "singleQuote": false,
+ "printWidth": 80,
+ "tabWidth": 2,
+ "useTabs": false,
+ "bracketSpacing": true,
+ "bracketSameLine": false,
+ "arrowParens": "avoid",
+ "endOfLine": "lf",
+ "quoteProps": "as-needed",
+ "jsxSingleQuote": false,
+ "proseWrap": "preserve"
+}
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 0000000..89842ca
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+ 资源聚合平台
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/public/.gitkeep b/client/public/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/client/public/__manus__/debug-collector.js b/client/public/__manus__/debug-collector.js
new file mode 100644
index 0000000..0504555
--- /dev/null
+++ b/client/public/__manus__/debug-collector.js
@@ -0,0 +1,821 @@
+/**
+ * Manus Debug Collector (agent-friendly)
+ *
+ * Captures:
+ * 1) Console logs
+ * 2) Network requests (fetch + XHR)
+ * 3) User interactions (semantic uiEvents: click/type/submit/nav/scroll/etc.)
+ *
+ * Data is periodically sent to /__manus__/logs
+ * Note: uiEvents are mirrored to sessionEvents for sessionReplay.log
+ */
+(function () {
+ "use strict";
+
+ // Prevent double initialization
+ if (window.__MANUS_DEBUG_COLLECTOR__) return;
+
+ // ==========================================================================
+ // Configuration
+ // ==========================================================================
+ const CONFIG = {
+ reportEndpoint: "/__manus__/logs",
+ bufferSize: {
+ console: 500,
+ network: 200,
+ // semantic, agent-friendly UI events
+ ui: 500,
+ },
+ reportInterval: 2000,
+ sensitiveFields: [
+ "password",
+ "token",
+ "secret",
+ "key",
+ "authorization",
+ "cookie",
+ "session",
+ ],
+ maxBodyLength: 10240,
+ // UI event logging privacy policy:
+ // - inputs matching sensitiveFields or type=password are masked by default
+ // - non-sensitive inputs log up to 200 chars
+ uiInputMaxLen: 200,
+ uiTextMaxLen: 80,
+ // Scroll throttling: minimum ms between scroll events
+ scrollThrottleMs: 500,
+ };
+
+ // ==========================================================================
+ // Storage
+ // ==========================================================================
+ const store = {
+ consoleLogs: [],
+ networkRequests: [],
+ uiEvents: [],
+ lastReportTime: Date.now(),
+ lastScrollTime: 0,
+ };
+
+ // ==========================================================================
+ // Utility Functions
+ // ==========================================================================
+
+ function sanitizeValue(value, depth) {
+ if (depth === void 0) depth = 0;
+ if (depth > 5) return "[Max Depth]";
+ if (value === null) return null;
+ if (value === undefined) return undefined;
+
+ if (typeof value === "string") {
+ return value.length > 1000 ? value.slice(0, 1000) + "...[truncated]" : value;
+ }
+
+ if (typeof value !== "object") return value;
+
+ if (Array.isArray(value)) {
+ return value.slice(0, 100).map(function (v) {
+ return sanitizeValue(v, depth + 1);
+ });
+ }
+
+ var sanitized = {};
+ for (var k in value) {
+ if (Object.prototype.hasOwnProperty.call(value, k)) {
+ var isSensitive = CONFIG.sensitiveFields.some(function (f) {
+ return k.toLowerCase().indexOf(f) !== -1;
+ });
+ if (isSensitive) {
+ sanitized[k] = "[REDACTED]";
+ } else {
+ sanitized[k] = sanitizeValue(value[k], depth + 1);
+ }
+ }
+ }
+ return sanitized;
+ }
+
+ function formatArg(arg) {
+ try {
+ if (arg instanceof Error) {
+ return { type: "Error", message: arg.message, stack: arg.stack };
+ }
+ if (typeof arg === "object") return sanitizeValue(arg);
+ return String(arg);
+ } catch (e) {
+ return "[Unserializable]";
+ }
+ }
+
+ function formatArgs(args) {
+ var result = [];
+ for (var i = 0; i < args.length; i++) result.push(formatArg(args[i]));
+ return result;
+ }
+
+ function pruneBuffer(buffer, maxSize) {
+ if (buffer.length > maxSize) buffer.splice(0, buffer.length - maxSize);
+ }
+
+ function tryParseJson(str) {
+ if (typeof str !== "string") return str;
+ try {
+ return JSON.parse(str);
+ } catch (e) {
+ return str;
+ }
+ }
+
+ // ==========================================================================
+ // Semantic UI Event Logging (agent-friendly)
+ // ==========================================================================
+
+ function shouldIgnoreTarget(target) {
+ try {
+ if (!target || !(target instanceof Element)) return false;
+ return !!target.closest(".manus-no-record");
+ } catch (e) {
+ return false;
+ }
+ }
+
+ function compactText(s, maxLen) {
+ try {
+ var t = (s || "").trim().replace(/\s+/g, " ");
+ if (!t) return "";
+ return t.length > maxLen ? t.slice(0, maxLen) + "…" : t;
+ } catch (e) {
+ return "";
+ }
+ }
+
+ function elText(el) {
+ try {
+ var t = el.innerText || el.textContent || "";
+ return compactText(t, CONFIG.uiTextMaxLen);
+ } catch (e) {
+ return "";
+ }
+ }
+
+ function describeElement(el) {
+ if (!el || !(el instanceof Element)) return null;
+
+ var getAttr = function (name) {
+ return el.getAttribute(name);
+ };
+
+ var tag = el.tagName ? el.tagName.toLowerCase() : null;
+ var id = el.id || null;
+ var name = getAttr("name") || null;
+ var role = getAttr("role") || null;
+ var ariaLabel = getAttr("aria-label") || null;
+
+ var dataLoc = getAttr("data-loc") || null;
+ var testId =
+ getAttr("data-testid") ||
+ getAttr("data-test-id") ||
+ getAttr("data-test") ||
+ null;
+
+ var type = tag === "input" ? (getAttr("type") || "text") : null;
+ var href = tag === "a" ? getAttr("href") || null : null;
+
+ // a small, stable hint for agents (avoid building full CSS paths)
+ var selectorHint = null;
+ if (testId) selectorHint = '[data-testid="' + testId + '"]';
+ else if (dataLoc) selectorHint = '[data-loc="' + dataLoc + '"]';
+ else if (id) selectorHint = "#" + id;
+ else selectorHint = tag || "unknown";
+
+ return {
+ tag: tag,
+ id: id,
+ name: name,
+ type: type,
+ role: role,
+ ariaLabel: ariaLabel,
+ testId: testId,
+ dataLoc: dataLoc,
+ href: href,
+ text: elText(el),
+ selectorHint: selectorHint,
+ };
+ }
+
+ function isSensitiveField(el) {
+ if (!el || !(el instanceof Element)) return false;
+ var tag = el.tagName ? el.tagName.toLowerCase() : "";
+ if (tag !== "input" && tag !== "textarea") return false;
+
+ var type = (el.getAttribute("type") || "").toLowerCase();
+ if (type === "password") return true;
+
+ var name = (el.getAttribute("name") || "").toLowerCase();
+ var id = (el.id || "").toLowerCase();
+
+ return CONFIG.sensitiveFields.some(function (f) {
+ return name.indexOf(f) !== -1 || id.indexOf(f) !== -1;
+ });
+ }
+
+ function getInputValueSafe(el) {
+ if (!el || !(el instanceof Element)) return null;
+ var tag = el.tagName ? el.tagName.toLowerCase() : "";
+ if (tag !== "input" && tag !== "textarea" && tag !== "select") return null;
+
+ var v = "";
+ try {
+ v = el.value != null ? String(el.value) : "";
+ } catch (e) {
+ v = "";
+ }
+
+ if (isSensitiveField(el)) return { masked: true, length: v.length };
+
+ if (v.length > CONFIG.uiInputMaxLen) v = v.slice(0, CONFIG.uiInputMaxLen) + "…";
+ return v;
+ }
+
+ function logUiEvent(kind, payload) {
+ var entry = {
+ timestamp: Date.now(),
+ kind: kind,
+ url: location.href,
+ viewport: { width: window.innerWidth, height: window.innerHeight },
+ payload: sanitizeValue(payload),
+ };
+ store.uiEvents.push(entry);
+ pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui);
+ }
+
+ function installUiEventListeners() {
+ // Clicks
+ document.addEventListener(
+ "click",
+ function (e) {
+ var t = e.target;
+ if (shouldIgnoreTarget(t)) return;
+ logUiEvent("click", {
+ target: describeElement(t),
+ x: e.clientX,
+ y: e.clientY,
+ });
+ },
+ true
+ );
+
+ // Typing "commit" events
+ document.addEventListener(
+ "change",
+ function (e) {
+ var t = e.target;
+ if (shouldIgnoreTarget(t)) return;
+ logUiEvent("change", {
+ target: describeElement(t),
+ value: getInputValueSafe(t),
+ });
+ },
+ true
+ );
+
+ document.addEventListener(
+ "focusin",
+ function (e) {
+ var t = e.target;
+ if (shouldIgnoreTarget(t)) return;
+ logUiEvent("focusin", { target: describeElement(t) });
+ },
+ true
+ );
+
+ document.addEventListener(
+ "focusout",
+ function (e) {
+ var t = e.target;
+ if (shouldIgnoreTarget(t)) return;
+ logUiEvent("focusout", {
+ target: describeElement(t),
+ value: getInputValueSafe(t),
+ });
+ },
+ true
+ );
+
+ // Enter/Escape are useful for form flows & modals
+ document.addEventListener(
+ "keydown",
+ function (e) {
+ if (e.key !== "Enter" && e.key !== "Escape") return;
+ var t = e.target;
+ if (shouldIgnoreTarget(t)) return;
+ logUiEvent("keydown", { key: e.key, target: describeElement(t) });
+ },
+ true
+ );
+
+ // Form submissions
+ document.addEventListener(
+ "submit",
+ function (e) {
+ var t = e.target;
+ if (shouldIgnoreTarget(t)) return;
+ logUiEvent("submit", { target: describeElement(t) });
+ },
+ true
+ );
+
+ // Throttled scroll events
+ window.addEventListener(
+ "scroll",
+ function () {
+ var now = Date.now();
+ if (now - store.lastScrollTime < CONFIG.scrollThrottleMs) return;
+ store.lastScrollTime = now;
+
+ logUiEvent("scroll", {
+ scrollX: window.scrollX,
+ scrollY: window.scrollY,
+ documentHeight: document.documentElement.scrollHeight,
+ viewportHeight: window.innerHeight,
+ });
+ },
+ { passive: true }
+ );
+
+ // Navigation tracking for SPAs
+ function nav(reason) {
+ logUiEvent("navigate", { reason: reason });
+ }
+
+ var origPush = history.pushState;
+ history.pushState = function () {
+ origPush.apply(this, arguments);
+ nav("pushState");
+ };
+
+ var origReplace = history.replaceState;
+ history.replaceState = function () {
+ origReplace.apply(this, arguments);
+ nav("replaceState");
+ };
+
+ window.addEventListener("popstate", function () {
+ nav("popstate");
+ });
+ window.addEventListener("hashchange", function () {
+ nav("hashchange");
+ });
+ }
+
+ // ==========================================================================
+ // Console Interception
+ // ==========================================================================
+
+ var originalConsole = {
+ log: console.log.bind(console),
+ debug: console.debug.bind(console),
+ info: console.info.bind(console),
+ warn: console.warn.bind(console),
+ error: console.error.bind(console),
+ };
+
+ ["log", "debug", "info", "warn", "error"].forEach(function (method) {
+ console[method] = function () {
+ var args = Array.prototype.slice.call(arguments);
+
+ var entry = {
+ timestamp: Date.now(),
+ level: method.toUpperCase(),
+ args: formatArgs(args),
+ stack: method === "error" ? new Error().stack : null,
+ };
+
+ store.consoleLogs.push(entry);
+ pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console);
+
+ originalConsole[method].apply(console, args);
+ };
+ });
+
+ window.addEventListener("error", function (event) {
+ store.consoleLogs.push({
+ timestamp: Date.now(),
+ level: "ERROR",
+ args: [
+ {
+ type: "UncaughtError",
+ message: event.message,
+ filename: event.filename,
+ lineno: event.lineno,
+ colno: event.colno,
+ stack: event.error ? event.error.stack : null,
+ },
+ ],
+ stack: event.error ? event.error.stack : null,
+ });
+ pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console);
+
+ // Mark an error moment in UI event stream for agents
+ logUiEvent("error", {
+ message: event.message,
+ filename: event.filename,
+ lineno: event.lineno,
+ colno: event.colno,
+ });
+ });
+
+ window.addEventListener("unhandledrejection", function (event) {
+ var reason = event.reason;
+ store.consoleLogs.push({
+ timestamp: Date.now(),
+ level: "ERROR",
+ args: [
+ {
+ type: "UnhandledRejection",
+ reason: reason && reason.message ? reason.message : String(reason),
+ stack: reason && reason.stack ? reason.stack : null,
+ },
+ ],
+ stack: reason && reason.stack ? reason.stack : null,
+ });
+ pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console);
+
+ logUiEvent("unhandledrejection", {
+ reason: reason && reason.message ? reason.message : String(reason),
+ });
+ });
+
+ // ==========================================================================
+ // Fetch Interception
+ // ==========================================================================
+
+ var originalFetch = window.fetch.bind(window);
+
+ window.fetch = function (input, init) {
+ init = init || {};
+ var startTime = Date.now();
+ // Handle string, Request object, or URL object
+ var url = typeof input === "string"
+ ? input
+ : (input && (input.url || input.href || String(input))) || "";
+ var method = init.method || (input && input.method) || "GET";
+
+ // Don't intercept internal requests
+ if (url.indexOf("/__manus__/") === 0) {
+ return originalFetch(input, init);
+ }
+
+ // Safely parse headers (avoid breaking if headers format is invalid)
+ var requestHeaders = {};
+ try {
+ if (init.headers) {
+ requestHeaders = Object.fromEntries(new Headers(init.headers).entries());
+ }
+ } catch (e) {
+ requestHeaders = { _parseError: true };
+ }
+
+ var entry = {
+ timestamp: startTime,
+ type: "fetch",
+ method: method.toUpperCase(),
+ url: url,
+ request: {
+ headers: requestHeaders,
+ body: init.body ? sanitizeValue(tryParseJson(init.body)) : null,
+ },
+ response: null,
+ duration: null,
+ error: null,
+ };
+
+ return originalFetch(input, init)
+ .then(function (response) {
+ entry.duration = Date.now() - startTime;
+
+ var contentType = (response.headers.get("content-type") || "").toLowerCase();
+ var contentLength = response.headers.get("content-length");
+
+ entry.response = {
+ status: response.status,
+ statusText: response.statusText,
+ headers: Object.fromEntries(response.headers.entries()),
+ body: null,
+ };
+
+ // Semantic network hint for agents on failures (sync, no need to wait for body)
+ if (response.status >= 400) {
+ logUiEvent("network_error", {
+ kind: "fetch",
+ method: entry.method,
+ url: entry.url,
+ status: response.status,
+ statusText: response.statusText,
+ });
+ }
+
+ // Skip body capture for streaming responses (SSE, etc.) to avoid memory leaks
+ var isStreaming = contentType.indexOf("text/event-stream") !== -1 ||
+ contentType.indexOf("application/stream") !== -1 ||
+ contentType.indexOf("application/x-ndjson") !== -1;
+ if (isStreaming) {
+ entry.response.body = "[Streaming response - not captured]";
+ store.networkRequests.push(entry);
+ pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
+ return response;
+ }
+
+ // Skip body capture for large responses to avoid memory issues
+ if (contentLength && parseInt(contentLength, 10) > CONFIG.maxBodyLength) {
+ entry.response.body = "[Response too large: " + contentLength + " bytes]";
+ store.networkRequests.push(entry);
+ pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
+ return response;
+ }
+
+ // Skip body capture for binary content types
+ var isBinary = contentType.indexOf("image/") !== -1 ||
+ contentType.indexOf("video/") !== -1 ||
+ contentType.indexOf("audio/") !== -1 ||
+ contentType.indexOf("application/octet-stream") !== -1 ||
+ contentType.indexOf("application/pdf") !== -1 ||
+ contentType.indexOf("application/zip") !== -1;
+ if (isBinary) {
+ entry.response.body = "[Binary content: " + contentType + "]";
+ store.networkRequests.push(entry);
+ pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
+ return response;
+ }
+
+ // For text responses, clone and read body in background
+ var clonedResponse = response.clone();
+
+ // Async: read body in background, don't block the response
+ clonedResponse
+ .text()
+ .then(function (text) {
+ if (text.length <= CONFIG.maxBodyLength) {
+ entry.response.body = sanitizeValue(tryParseJson(text));
+ } else {
+ entry.response.body = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]";
+ }
+ })
+ .catch(function () {
+ entry.response.body = "[Unable to read body]";
+ })
+ .finally(function () {
+ store.networkRequests.push(entry);
+ pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
+ });
+
+ // Return response immediately, don't wait for body reading
+ return response;
+ })
+ .catch(function (error) {
+ entry.duration = Date.now() - startTime;
+ entry.error = { message: error.message, stack: error.stack };
+
+ store.networkRequests.push(entry);
+ pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
+
+ logUiEvent("network_error", {
+ kind: "fetch",
+ method: entry.method,
+ url: entry.url,
+ message: error.message,
+ });
+
+ throw error;
+ });
+ };
+
+ // ==========================================================================
+ // XHR Interception
+ // ==========================================================================
+
+ var originalXHROpen = XMLHttpRequest.prototype.open;
+ var originalXHRSend = XMLHttpRequest.prototype.send;
+
+ XMLHttpRequest.prototype.open = function (method, url) {
+ this._manusData = {
+ method: (method || "GET").toUpperCase(),
+ url: url,
+ startTime: null,
+ };
+ return originalXHROpen.apply(this, arguments);
+ };
+
+ XMLHttpRequest.prototype.send = function (body) {
+ var xhr = this;
+
+ if (
+ xhr._manusData &&
+ xhr._manusData.url &&
+ xhr._manusData.url.indexOf("/__manus__/") !== 0
+ ) {
+ xhr._manusData.startTime = Date.now();
+ xhr._manusData.requestBody = body ? sanitizeValue(tryParseJson(body)) : null;
+
+ xhr.addEventListener("load", function () {
+ var contentType = (xhr.getResponseHeader("content-type") || "").toLowerCase();
+ var responseBody = null;
+
+ // Skip body capture for streaming responses
+ var isStreaming = contentType.indexOf("text/event-stream") !== -1 ||
+ contentType.indexOf("application/stream") !== -1 ||
+ contentType.indexOf("application/x-ndjson") !== -1;
+
+ // Skip body capture for binary content types
+ var isBinary = contentType.indexOf("image/") !== -1 ||
+ contentType.indexOf("video/") !== -1 ||
+ contentType.indexOf("audio/") !== -1 ||
+ contentType.indexOf("application/octet-stream") !== -1 ||
+ contentType.indexOf("application/pdf") !== -1 ||
+ contentType.indexOf("application/zip") !== -1;
+
+ if (isStreaming) {
+ responseBody = "[Streaming response - not captured]";
+ } else if (isBinary) {
+ responseBody = "[Binary content: " + contentType + "]";
+ } else {
+ // Safe to read responseText for text responses
+ try {
+ var text = xhr.responseText || "";
+ if (text.length > CONFIG.maxBodyLength) {
+ responseBody = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]";
+ } else {
+ responseBody = sanitizeValue(tryParseJson(text));
+ }
+ } catch (e) {
+ // responseText may throw for non-text responses
+ responseBody = "[Unable to read response: " + e.message + "]";
+ }
+ }
+
+ var entry = {
+ timestamp: xhr._manusData.startTime,
+ type: "xhr",
+ method: xhr._manusData.method,
+ url: xhr._manusData.url,
+ request: { body: xhr._manusData.requestBody },
+ response: {
+ status: xhr.status,
+ statusText: xhr.statusText,
+ body: responseBody,
+ },
+ duration: Date.now() - xhr._manusData.startTime,
+ error: null,
+ };
+
+ store.networkRequests.push(entry);
+ pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
+
+ if (entry.response && entry.response.status >= 400) {
+ logUiEvent("network_error", {
+ kind: "xhr",
+ method: entry.method,
+ url: entry.url,
+ status: entry.response.status,
+ statusText: entry.response.statusText,
+ });
+ }
+ });
+
+ xhr.addEventListener("error", function () {
+ var entry = {
+ timestamp: xhr._manusData.startTime,
+ type: "xhr",
+ method: xhr._manusData.method,
+ url: xhr._manusData.url,
+ request: { body: xhr._manusData.requestBody },
+ response: null,
+ duration: Date.now() - xhr._manusData.startTime,
+ error: { message: "Network error" },
+ };
+
+ store.networkRequests.push(entry);
+ pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
+
+ logUiEvent("network_error", {
+ kind: "xhr",
+ method: entry.method,
+ url: entry.url,
+ message: "Network error",
+ });
+ });
+ }
+
+ return originalXHRSend.apply(this, arguments);
+ };
+
+ // ==========================================================================
+ // Data Reporting
+ // ==========================================================================
+
+ function reportLogs() {
+ var consoleLogs = store.consoleLogs.splice(0);
+ var networkRequests = store.networkRequests.splice(0);
+ var uiEvents = store.uiEvents.splice(0);
+
+ // Skip if no new data
+ if (
+ consoleLogs.length === 0 &&
+ networkRequests.length === 0 &&
+ uiEvents.length === 0
+ ) {
+ return Promise.resolve();
+ }
+
+ var payload = {
+ timestamp: Date.now(),
+ consoleLogs: consoleLogs,
+ networkRequests: networkRequests,
+ // Mirror uiEvents to sessionEvents for sessionReplay.log
+ sessionEvents: uiEvents,
+ // agent-friendly semantic events
+ uiEvents: uiEvents,
+ };
+
+ return originalFetch(CONFIG.reportEndpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ }).catch(function () {
+ // Put data back on failure (but respect limits)
+ store.consoleLogs = consoleLogs.concat(store.consoleLogs);
+ store.networkRequests = networkRequests.concat(store.networkRequests);
+ store.uiEvents = uiEvents.concat(store.uiEvents);
+
+ pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console);
+ pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
+ pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui);
+ });
+ }
+
+ // Periodic reporting
+ setInterval(reportLogs, CONFIG.reportInterval);
+
+ // Report on page unload
+ window.addEventListener("beforeunload", function () {
+ var consoleLogs = store.consoleLogs;
+ var networkRequests = store.networkRequests;
+ var uiEvents = store.uiEvents;
+
+ if (
+ consoleLogs.length === 0 &&
+ networkRequests.length === 0 &&
+ uiEvents.length === 0
+ ) {
+ return;
+ }
+
+ var payload = {
+ timestamp: Date.now(),
+ consoleLogs: consoleLogs,
+ networkRequests: networkRequests,
+ // Mirror uiEvents to sessionEvents for sessionReplay.log
+ sessionEvents: uiEvents,
+ uiEvents: uiEvents,
+ };
+
+ if (navigator.sendBeacon) {
+ var payloadStr = JSON.stringify(payload);
+ // sendBeacon has ~64KB limit, truncate if too large
+ var MAX_BEACON_SIZE = 60000; // Leave some margin
+ if (payloadStr.length > MAX_BEACON_SIZE) {
+ // Prioritize: keep recent events, drop older logs
+ var truncatedPayload = {
+ timestamp: Date.now(),
+ consoleLogs: consoleLogs.slice(-50),
+ networkRequests: networkRequests.slice(-20),
+ sessionEvents: uiEvents.slice(-100),
+ uiEvents: uiEvents.slice(-100),
+ _truncated: true,
+ };
+ payloadStr = JSON.stringify(truncatedPayload);
+ }
+ navigator.sendBeacon(CONFIG.reportEndpoint, payloadStr);
+ }
+ });
+
+ // ==========================================================================
+ // Initialization
+ // ==========================================================================
+
+ // Install semantic UI listeners ASAP
+ try {
+ installUiEventListeners();
+ } catch (e) {
+ console.warn("[Manus] Failed to install UI listeners:", e);
+ }
+
+ // Mark as initialized
+ window.__MANUS_DEBUG_COLLECTOR__ = {
+ version: "2.0-no-rrweb",
+ store: store,
+ forceReport: reportLogs,
+ };
+
+ console.debug("[Manus] Debug collector initialized (no rrweb, UI events only)");
+})();
diff --git a/client/src/App.tsx b/client/src/App.tsx
new file mode 100644
index 0000000..fbef5ca
--- /dev/null
+++ b/client/src/App.tsx
@@ -0,0 +1,46 @@
+import { Toaster } from "@/components/ui/sonner";
+import { TooltipProvider } from "@/components/ui/tooltip";
+import NotFound from "@/pages/NotFound";
+import { Route, Switch } from "wouter";
+import ErrorBoundary from "./components/ErrorBoundary";
+import { ThemeProvider } from "./contexts/ThemeContext";
+import Home from "./pages/Home";
+import Products from "./pages/Products";
+import Bounties from "./pages/Bounties";
+import BountyDetail from "./pages/BountyDetail";
+import Dashboard from "./pages/Dashboard";
+import Favorites from "./pages/Favorites";
+import ProductComparison from "./pages/ProductComparison";
+import AdminDashboard from "./pages/AdminDashboard";
+
+function Router() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function App() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+export default App;
diff --git a/client/src/_core/hooks/useAuth.ts b/client/src/_core/hooks/useAuth.ts
new file mode 100644
index 0000000..dcef9bd
--- /dev/null
+++ b/client/src/_core/hooks/useAuth.ts
@@ -0,0 +1,84 @@
+import { getLoginUrl } from "@/const";
+import { trpc } from "@/lib/trpc";
+import { TRPCClientError } from "@trpc/client";
+import { useCallback, useEffect, useMemo } from "react";
+
+type UseAuthOptions = {
+ redirectOnUnauthenticated?: boolean;
+ redirectPath?: string;
+};
+
+export function useAuth(options?: UseAuthOptions) {
+ const { redirectOnUnauthenticated = false, redirectPath = getLoginUrl() } =
+ options ?? {};
+ const utils = trpc.useUtils();
+
+ const meQuery = trpc.auth.me.useQuery(undefined, {
+ retry: false,
+ refetchOnWindowFocus: false,
+ });
+
+ const logoutMutation = trpc.auth.logout.useMutation({
+ onSuccess: () => {
+ utils.auth.me.setData(undefined, null);
+ },
+ });
+
+ const logout = useCallback(async () => {
+ try {
+ await logoutMutation.mutateAsync();
+ } catch (error: unknown) {
+ if (
+ error instanceof TRPCClientError &&
+ error.data?.code === "UNAUTHORIZED"
+ ) {
+ return;
+ }
+ throw error;
+ } finally {
+ utils.auth.me.setData(undefined, null);
+ await utils.auth.me.invalidate();
+ }
+ }, [logoutMutation, utils]);
+
+ const state = useMemo(() => {
+ localStorage.setItem(
+ "manus-runtime-user-info",
+ JSON.stringify(meQuery.data)
+ );
+ return {
+ user: meQuery.data ?? null,
+ loading: meQuery.isLoading || logoutMutation.isPending,
+ error: meQuery.error ?? logoutMutation.error ?? null,
+ isAuthenticated: Boolean(meQuery.data),
+ };
+ }, [
+ meQuery.data,
+ meQuery.error,
+ meQuery.isLoading,
+ logoutMutation.error,
+ logoutMutation.isPending,
+ ]);
+
+ useEffect(() => {
+ if (!redirectOnUnauthenticated) return;
+ if (meQuery.isLoading || logoutMutation.isPending) return;
+ if (state.user) return;
+ if (typeof window === "undefined") return;
+ if (window.location.pathname === redirectPath) return;
+
+ window.location.href = redirectPath
+ }, [
+ redirectOnUnauthenticated,
+ redirectPath,
+ logoutMutation.isPending,
+ meQuery.isLoading,
+ state.user,
+ ]);
+
+ return {
+ ...state,
+ refresh: () => meQuery.refetch(),
+ logout,
+ };
+}
diff --git a/client/src/components/AIChatBox.tsx b/client/src/components/AIChatBox.tsx
new file mode 100644
index 0000000..1c00871
--- /dev/null
+++ b/client/src/components/AIChatBox.tsx
@@ -0,0 +1,335 @@
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { cn } from "@/lib/utils";
+import { Loader2, Send, User, Sparkles } from "lucide-react";
+import { useState, useEffect, useRef } from "react";
+import { Streamdown } from "streamdown";
+
+/**
+ * Message type matching server-side LLM Message interface
+ */
+export type Message = {
+ role: "system" | "user" | "assistant";
+ content: string;
+};
+
+export type AIChatBoxProps = {
+ /**
+ * Messages array to display in the chat.
+ * Should match the format used by invokeLLM on the server.
+ */
+ messages: Message[];
+
+ /**
+ * Callback when user sends a message.
+ * Typically you'll call a tRPC mutation here to invoke the LLM.
+ */
+ onSendMessage: (content: string) => void;
+
+ /**
+ * Whether the AI is currently generating a response
+ */
+ isLoading?: boolean;
+
+ /**
+ * Placeholder text for the input field
+ */
+ placeholder?: string;
+
+ /**
+ * Custom className for the container
+ */
+ className?: string;
+
+ /**
+ * Height of the chat box (default: 600px)
+ */
+ height?: string | number;
+
+ /**
+ * Empty state message to display when no messages
+ */
+ emptyStateMessage?: string;
+
+ /**
+ * Suggested prompts to display in empty state
+ * Click to send directly
+ */
+ suggestedPrompts?: string[];
+};
+
+/**
+ * A ready-to-use AI chat box component that integrates with the LLM system.
+ *
+ * Features:
+ * - Matches server-side Message interface for seamless integration
+ * - Markdown rendering with Streamdown
+ * - Auto-scrolls to latest message
+ * - Loading states
+ * - Uses global theme colors from index.css
+ *
+ * @example
+ * ```tsx
+ * const ChatPage = () => {
+ * const [messages, setMessages] = useState([
+ * { role: "system", content: "You are a helpful assistant." }
+ * ]);
+ *
+ * const chatMutation = trpc.ai.chat.useMutation({
+ * onSuccess: (response) => {
+ * // Assuming your tRPC endpoint returns the AI response as a string
+ * setMessages(prev => [...prev, {
+ * role: "assistant",
+ * content: response
+ * }]);
+ * },
+ * onError: (error) => {
+ * console.error("Chat error:", error);
+ * // Optionally show error message to user
+ * }
+ * });
+ *
+ * const handleSend = (content: string) => {
+ * const newMessages = [...messages, { role: "user", content }];
+ * setMessages(newMessages);
+ * chatMutation.mutate({ messages: newMessages });
+ * };
+ *
+ * return (
+ *
+ * );
+ * };
+ * ```
+ */
+export function AIChatBox({
+ messages,
+ onSendMessage,
+ isLoading = false,
+ placeholder = "Type your message...",
+ className,
+ height = "600px",
+ emptyStateMessage = "Start a conversation with AI",
+ suggestedPrompts,
+}: AIChatBoxProps) {
+ const [input, setInput] = useState("");
+ const scrollAreaRef = useRef(null);
+ const containerRef = useRef(null);
+ const inputAreaRef = useRef(null);
+ const textareaRef = useRef(null);
+
+ // Filter out system messages
+ const displayMessages = messages.filter((msg) => msg.role !== "system");
+
+ // Calculate min-height for last assistant message to push user message to top
+ const [minHeightForLastMessage, setMinHeightForLastMessage] = useState(0);
+
+ useEffect(() => {
+ if (containerRef.current && inputAreaRef.current) {
+ const containerHeight = containerRef.current.offsetHeight;
+ const inputHeight = inputAreaRef.current.offsetHeight;
+ const scrollAreaHeight = containerHeight - inputHeight;
+
+ // Reserve space for:
+ // - padding (p-4 = 32px top+bottom)
+ // - user message: 40px (item height) + 16px (margin-top from space-y-4) = 56px
+ // Note: margin-bottom is not counted because it naturally pushes the assistant message down
+ const userMessageReservedHeight = 56;
+ const calculatedHeight = scrollAreaHeight - 32 - userMessageReservedHeight;
+
+ setMinHeightForLastMessage(Math.max(0, calculatedHeight));
+ }
+ }, []);
+
+ // Scroll to bottom helper function with smooth animation
+ const scrollToBottom = () => {
+ const viewport = scrollAreaRef.current?.querySelector(
+ '[data-radix-scroll-area-viewport]'
+ ) as HTMLDivElement;
+
+ if (viewport) {
+ requestAnimationFrame(() => {
+ viewport.scrollTo({
+ top: viewport.scrollHeight,
+ behavior: 'smooth'
+ });
+ });
+ }
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ const trimmedInput = input.trim();
+ if (!trimmedInput || isLoading) return;
+
+ onSendMessage(trimmedInput);
+ setInput("");
+
+ // Scroll immediately after sending
+ scrollToBottom();
+
+ // Keep focus on input
+ textareaRef.current?.focus();
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit(e);
+ }
+ };
+
+ return (
+
+ {/* Messages Area */}
+
+ {displayMessages.length === 0 ? (
+
+
+
+
+
{emptyStateMessage}
+
+
+ {suggestedPrompts && suggestedPrompts.length > 0 && (
+
+ {suggestedPrompts.map((prompt, index) => (
+
+ ))}
+
+ )}
+
+
+ ) : (
+
+
+ {displayMessages.map((message, index) => {
+ // Apply min-height to last message only if NOT loading (when loading, the loading indicator gets it)
+ const isLastMessage = index === displayMessages.length - 1;
+ const shouldApplyMinHeight =
+ isLastMessage && !isLoading && minHeightForLastMessage > 0;
+
+ return (
+
+ {message.role === "assistant" && (
+
+
+
+ )}
+
+
+ {message.role === "assistant" ? (
+
+ {message.content}
+
+ ) : (
+
+ {message.content}
+
+ )}
+
+
+ {message.role === "user" && (
+
+
+
+ )}
+
+ );
+ })}
+
+ {isLoading && (
+
0
+ ? { minHeight: `${minHeightForLastMessage}px` }
+ : undefined
+ }
+ >
+
+
+
+
+
+
+
+ )}
+
+
+ )}
+
+
+ {/* Input Area */}
+
+
+ );
+}
diff --git a/client/src/components/AdvancedFilter.tsx b/client/src/components/AdvancedFilter.tsx
new file mode 100644
index 0000000..c690aa8
--- /dev/null
+++ b/client/src/components/AdvancedFilter.tsx
@@ -0,0 +1,164 @@
+import React, { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { Card } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { X, Filter } from 'lucide-react';
+import { trpc } from '@/lib/trpc';
+
+export interface FilterOptions {
+ startDate?: Date;
+ endDate?: Date;
+ categoryId?: number;
+}
+
+interface AdvancedFilterProps {
+ onFilterChange: (filters: FilterOptions) => void;
+ showCategoryFilter?: boolean;
+}
+
+export default function AdvancedFilter({
+ onFilterChange,
+ showCategoryFilter = true,
+}: AdvancedFilterProps) {
+ const [startDate, setStartDate] = useState('');
+ const [endDate, setEndDate] = useState('');
+ const [categoryId, setCategoryId] = useState('');
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ // 获取分类列表
+ const { data: categories = [] } = trpc.admin.category.list.useQuery();
+
+ const handleFilter = () => {
+ const filters: FilterOptions = {};
+
+ if (startDate) {
+ filters.startDate = new Date(startDate);
+ }
+
+ if (endDate) {
+ filters.endDate = new Date(endDate);
+ }
+
+ if (categoryId) {
+ filters.categoryId = parseInt(categoryId);
+ }
+
+ onFilterChange(filters);
+ };
+
+ const handleReset = () => {
+ setStartDate('');
+ setEndDate('');
+ setCategoryId('');
+ onFilterChange({});
+ };
+
+ const hasActiveFilters = startDate || endDate || categoryId;
+
+ return (
+
+
+
+ {isExpanded && (
+
+
+ {/* 开始日期 */}
+
+
+ setStartDate(e.target.value)}
+ className="w-full"
+ />
+
+
+ {/* 结束日期 */}
+
+
+ setEndDate(e.target.value)}
+ className="w-full"
+ />
+
+
+ {/* 分类筛选 */}
+ {showCategoryFilter && (
+
+
+
+
+ )}
+
+ {/* 操作按钮 */}
+
+
+ {hasActiveFilters && (
+
+ )}
+
+
+
+ {/* 筛选提示 */}
+ {hasActiveFilters && (
+
+ 已应用筛选条件:
+ {startDate && 开始日期: {startDate}}
+ {endDate && 结束日期: {endDate}}
+ {categoryId && (
+
+ 分类: {categories.find((c: any) => c.id === parseInt(categoryId))?.name}
+
+ )}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx
new file mode 100644
index 0000000..0bf7437
--- /dev/null
+++ b/client/src/components/DashboardLayout.tsx
@@ -0,0 +1,264 @@
+import { useAuth } from "@/_core/hooks/useAuth";
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarHeader,
+ SidebarInset,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarProvider,
+ SidebarTrigger,
+ useSidebar,
+} from "@/components/ui/sidebar";
+import { getLoginUrl } from "@/const";
+import { useIsMobile } from "@/hooks/useMobile";
+import { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react";
+import { CSSProperties, useEffect, useRef, useState } from "react";
+import { useLocation } from "wouter";
+import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
+import { Button } from "./ui/button";
+
+const menuItems = [
+ { icon: LayoutDashboard, label: "Page 1", path: "/" },
+ { icon: Users, label: "Page 2", path: "/some-path" },
+];
+
+const SIDEBAR_WIDTH_KEY = "sidebar-width";
+const DEFAULT_WIDTH = 280;
+const MIN_WIDTH = 200;
+const MAX_WIDTH = 480;
+
+export default function DashboardLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const [sidebarWidth, setSidebarWidth] = useState(() => {
+ const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY);
+ return saved ? parseInt(saved, 10) : DEFAULT_WIDTH;
+ });
+ const { loading, user } = useAuth();
+
+ useEffect(() => {
+ localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString());
+ }, [sidebarWidth]);
+
+ if (loading) {
+ return
+ }
+
+ if (!user) {
+ return (
+
+
+
+
+ Sign in to continue
+
+
+ Access to this dashboard requires authentication. Continue to launch the login flow.
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+type DashboardLayoutContentProps = {
+ children: React.ReactNode;
+ setSidebarWidth: (width: number) => void;
+};
+
+function DashboardLayoutContent({
+ children,
+ setSidebarWidth,
+}: DashboardLayoutContentProps) {
+ const { user, logout } = useAuth();
+ const [location, setLocation] = useLocation();
+ const { state, toggleSidebar } = useSidebar();
+ const isCollapsed = state === "collapsed";
+ const [isResizing, setIsResizing] = useState(false);
+ const sidebarRef = useRef(null);
+ const activeMenuItem = menuItems.find(item => item.path === location);
+ const isMobile = useIsMobile();
+
+ useEffect(() => {
+ if (isCollapsed) {
+ setIsResizing(false);
+ }
+ }, [isCollapsed]);
+
+ useEffect(() => {
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!isResizing) return;
+
+ const sidebarLeft = sidebarRef.current?.getBoundingClientRect().left ?? 0;
+ const newWidth = e.clientX - sidebarLeft;
+ if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
+ setSidebarWidth(newWidth);
+ }
+ };
+
+ const handleMouseUp = () => {
+ setIsResizing(false);
+ };
+
+ if (isResizing) {
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ document.body.style.cursor = "col-resize";
+ document.body.style.userSelect = "none";
+ }
+
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ document.body.style.cursor = "";
+ document.body.style.userSelect = "";
+ };
+ }, [isResizing, setSidebarWidth]);
+
+ return (
+ <>
+
+
+
+
+
+ {!isCollapsed ? (
+
+
+ Navigation
+
+
+ ) : null}
+
+
+
+
+
+ {menuItems.map(item => {
+ const isActive = location === item.path;
+ return (
+
+ setLocation(item.path)}
+ tooltip={item.label}
+ className={`h-10 transition-all font-normal`}
+ >
+
+ {item.label}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+
+ Sign out
+
+
+
+
+
+
{
+ if (isCollapsed) return;
+ setIsResizing(true);
+ }}
+ style={{ zIndex: 50 }}
+ />
+
+
+
+ {isMobile && (
+
+
+
+
+
+
+ {activeMenuItem?.label ?? "Menu"}
+
+
+
+
+
+ )}
+ {children}
+
+ >
+ );
+}
diff --git a/client/src/components/DashboardLayoutSkeleton.tsx b/client/src/components/DashboardLayoutSkeleton.tsx
new file mode 100644
index 0000000..9ae9335
--- /dev/null
+++ b/client/src/components/DashboardLayoutSkeleton.tsx
@@ -0,0 +1,46 @@
+import { Skeleton } from './ui/skeleton';
+
+export function DashboardLayoutSkeleton() {
+ return (
+
+ {/* Sidebar skeleton */}
+
+ {/* Logo area */}
+
+
+
+
+
+ {/* Menu items */}
+
+
+
+
+
+
+ {/* User profile area at bottom */}
+
+
+
+ {/* Main content skeleton */}
+
+ {/* Content blocks */}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/ErrorBoundary.tsx b/client/src/components/ErrorBoundary.tsx
new file mode 100644
index 0000000..1422986
--- /dev/null
+++ b/client/src/components/ErrorBoundary.tsx
@@ -0,0 +1,62 @@
+import { cn } from "@/lib/utils";
+import { AlertTriangle, RotateCcw } from "lucide-react";
+import { Component, ReactNode } from "react";
+
+interface Props {
+ children: ReactNode;
+}
+
+interface State {
+ hasError: boolean;
+ error: Error | null;
+}
+
+class ErrorBoundary extends Component
{
+ constructor(props: Props) {
+ super(props);
+ this.state = { hasError: false, error: null };
+ }
+
+ static getDerivedStateFromError(error: Error): State {
+ return { hasError: true, error };
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+
+
+
+
An unexpected error occurred.
+
+
+
+ {this.state.error?.stack}
+
+
+
+
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+export default ErrorBoundary;
diff --git a/client/src/components/ManusDialog.tsx b/client/src/components/ManusDialog.tsx
new file mode 100644
index 0000000..b3d2293
--- /dev/null
+++ b/client/src/components/ManusDialog.tsx
@@ -0,0 +1,89 @@
+import { useEffect, useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogTitle,
+} from "@/components/ui/dialog";
+
+interface ManusDialogProps {
+ title?: string;
+ logo?: string;
+ open?: boolean;
+ onLogin: () => void;
+ onOpenChange?: (open: boolean) => void;
+ onClose?: () => void;
+}
+
+export function ManusDialog({
+ title,
+ logo,
+ open = false,
+ onLogin,
+ onOpenChange,
+ onClose,
+}: ManusDialogProps) {
+ const [internalOpen, setInternalOpen] = useState(open);
+
+ useEffect(() => {
+ if (!onOpenChange) {
+ setInternalOpen(open);
+ }
+ }, [open, onOpenChange]);
+
+ const handleOpenChange = (nextOpen: boolean) => {
+ if (onOpenChange) {
+ onOpenChange(nextOpen);
+ } else {
+ setInternalOpen(nextOpen);
+ }
+
+ if (!nextOpen) {
+ onClose?.();
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/client/src/components/Map.tsx b/client/src/components/Map.tsx
new file mode 100644
index 0000000..4849e05
--- /dev/null
+++ b/client/src/components/Map.tsx
@@ -0,0 +1,155 @@
+/**
+ * GOOGLE MAPS FRONTEND INTEGRATION - ESSENTIAL GUIDE
+ *
+ * USAGE FROM PARENT COMPONENT:
+ * ======
+ *
+ * const mapRef = useRef(null);
+ *
+ * {
+ * mapRef.current = map; // Store to control map from parent anytime, google map itself is in charge of the re-rendering, not react state.
+ *
+ *
+ * ======
+ * Available Libraries and Core Features:
+ * -------------------------------
+ * 📍 MARKER (from `marker` library)
+ * - Attaches to map using { map, position }
+ * new google.maps.marker.AdvancedMarkerElement({
+ * map,
+ * position: { lat: 37.7749, lng: -122.4194 },
+ * title: "San Francisco",
+ * });
+ *
+ * -------------------------------
+ * 🏢 PLACES (from `places` library)
+ * - Does not attach directly to map; use data with your map manually.
+ * const place = new google.maps.places.Place({ id: PLACE_ID });
+ * await place.fetchFields({ fields: ["displayName", "location"] });
+ * map.setCenter(place.location);
+ * new google.maps.marker.AdvancedMarkerElement({ map, position: place.location });
+ *
+ * -------------------------------
+ * 🧭 GEOCODER (from `geocoding` library)
+ * - Standalone service; manually apply results to map.
+ * const geocoder = new google.maps.Geocoder();
+ * geocoder.geocode({ address: "New York" }, (results, status) => {
+ * if (status === "OK" && results[0]) {
+ * map.setCenter(results[0].geometry.location);
+ * new google.maps.marker.AdvancedMarkerElement({
+ * map,
+ * position: results[0].geometry.location,
+ * });
+ * }
+ * });
+ *
+ * -------------------------------
+ * 📐 GEOMETRY (from `geometry` library)
+ * - Pure utility functions; not attached to map.
+ * const dist = google.maps.geometry.spherical.computeDistanceBetween(p1, p2);
+ *
+ * -------------------------------
+ * 🛣️ ROUTES (from `routes` library)
+ * - Combines DirectionsService (standalone) + DirectionsRenderer (map-attached)
+ * const directionsService = new google.maps.DirectionsService();
+ * const directionsRenderer = new google.maps.DirectionsRenderer({ map });
+ * directionsService.route(
+ * { origin, destination, travelMode: "DRIVING" },
+ * (res, status) => status === "OK" && directionsRenderer.setDirections(res)
+ * );
+ *
+ * -------------------------------
+ * 🌦️ MAP LAYERS (attach directly to map)
+ * - new google.maps.TrafficLayer().setMap(map);
+ * - new google.maps.TransitLayer().setMap(map);
+ * - new google.maps.BicyclingLayer().setMap(map);
+ *
+ * -------------------------------
+ * ✅ SUMMARY
+ * - “map-attached” → AdvancedMarkerElement, DirectionsRenderer, Layers.
+ * - “standalone” → Geocoder, DirectionsService, DistanceMatrixService, ElevationService.
+ * - “data-only” → Place, Geometry utilities.
+ */
+
+///
+
+import { useEffect, useRef } from "react";
+import { usePersistFn } from "@/hooks/usePersistFn";
+import { cn } from "@/lib/utils";
+
+declare global {
+ interface Window {
+ google?: typeof google;
+ }
+}
+
+const API_KEY = import.meta.env.VITE_FRONTEND_FORGE_API_KEY;
+const FORGE_BASE_URL =
+ import.meta.env.VITE_FRONTEND_FORGE_API_URL ||
+ "https://forge.butterfly-effect.dev";
+const MAPS_PROXY_URL = `${FORGE_BASE_URL}/v1/maps/proxy`;
+
+function loadMapScript() {
+ return new Promise(resolve => {
+ const script = document.createElement("script");
+ script.src = `${MAPS_PROXY_URL}/maps/api/js?key=${API_KEY}&v=weekly&libraries=marker,places,geocoding,geometry`;
+ script.async = true;
+ script.crossOrigin = "anonymous";
+ script.onload = () => {
+ resolve(null);
+ script.remove(); // Clean up immediately
+ };
+ script.onerror = () => {
+ console.error("Failed to load Google Maps script");
+ };
+ document.head.appendChild(script);
+ });
+}
+
+interface MapViewProps {
+ className?: string;
+ initialCenter?: google.maps.LatLngLiteral;
+ initialZoom?: number;
+ onMapReady?: (map: google.maps.Map) => void;
+}
+
+export function MapView({
+ className,
+ initialCenter = { lat: 37.7749, lng: -122.4194 },
+ initialZoom = 12,
+ onMapReady,
+}: MapViewProps) {
+ const mapContainer = useRef(null);
+ const map = useRef(null);
+
+ const init = usePersistFn(async () => {
+ await loadMapScript();
+ if (!mapContainer.current) {
+ console.error("Map container not found");
+ return;
+ }
+ map.current = new window.google.maps.Map(mapContainer.current, {
+ zoom: initialZoom,
+ center: initialCenter,
+ mapTypeControl: true,
+ fullscreenControl: true,
+ zoomControl: true,
+ streetViewControl: true,
+ mapId: "DEMO_MAP_ID",
+ });
+ if (onMapReady) {
+ onMapReady(map.current);
+ }
+ });
+
+ useEffect(() => {
+ init();
+ }, [init]);
+
+ return (
+
+ );
+}
diff --git a/client/src/components/ui/accordion.tsx b/client/src/components/ui/accordion.tsx
new file mode 100644
index 0000000..62705e3
--- /dev/null
+++ b/client/src/components/ui/accordion.tsx
@@ -0,0 +1,64 @@
+import * as React from "react";
+import * as AccordionPrimitive from "@radix-ui/react-accordion";
+import { ChevronDownIcon } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+function Accordion({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function AccordionItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AccordionTrigger({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ svg]:rotate-180",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+ );
+}
+
+function AccordionContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/client/src/components/ui/alert-dialog.tsx b/client/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..6949979
--- /dev/null
+++ b/client/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,155 @@
+import * as React from "react";
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
+
+import { cn } from "@/lib/utils";
+import { buttonVariants } from "@/components/ui/button";
+
+function AlertDialog({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ );
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogCancel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+};
diff --git a/client/src/components/ui/alert.tsx b/client/src/components/ui/alert.tsx
new file mode 100644
index 0000000..5b1a0b5
--- /dev/null
+++ b/client/src/components/ui/alert.tsx
@@ -0,0 +1,66 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
+ {
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive:
+ "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+);
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ );
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export { Alert, AlertTitle, AlertDescription };
diff --git a/client/src/components/ui/aspect-ratio.tsx b/client/src/components/ui/aspect-ratio.tsx
new file mode 100644
index 0000000..01d045d
--- /dev/null
+++ b/client/src/components/ui/aspect-ratio.tsx
@@ -0,0 +1,9 @@
+import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
+
+function AspectRatio({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+export { AspectRatio };
diff --git a/client/src/components/ui/avatar.tsx b/client/src/components/ui/avatar.tsx
new file mode 100644
index 0000000..02305fd
--- /dev/null
+++ b/client/src/components/ui/avatar.tsx
@@ -0,0 +1,51 @@
+import * as React from "react";
+import * as AvatarPrimitive from "@radix-ui/react-avatar";
+
+import { cn } from "@/lib/utils";
+
+function Avatar({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Avatar, AvatarImage, AvatarFallback };
diff --git a/client/src/components/ui/badge.tsx b/client/src/components/ui/badge.tsx
new file mode 100644
index 0000000..83750ed
--- /dev/null
+++ b/client/src/components/ui/badge.tsx
@@ -0,0 +1,46 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+);
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span";
+
+ return (
+
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/client/src/components/ui/breadcrumb.tsx b/client/src/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000..9d88a37
--- /dev/null
+++ b/client/src/components/ui/breadcrumb.tsx
@@ -0,0 +1,109 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { ChevronRight, MoreHorizontal } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
+ return ;
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
+ return (
+
+ );
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ );
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean;
+}) {
+ const Comp = asChild ? Slot : "a";
+
+ return (
+
+ );
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+ svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? }
+
+ );
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+
+ More
+
+ );
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+};
diff --git a/client/src/components/ui/button-group.tsx b/client/src/components/ui/button-group.tsx
new file mode 100644
index 0000000..30139ec
--- /dev/null
+++ b/client/src/components/ui/button-group.tsx
@@ -0,0 +1,83 @@
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+import { Separator } from "@/components/ui/separator";
+
+const buttonGroupVariants = cva(
+ "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
+ vertical:
+ "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
+ },
+ },
+ defaultVariants: {
+ orientation: "horizontal",
+ },
+ }
+);
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ );
+}
+
+function ButtonGroupText({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ asChild?: boolean;
+}) {
+ const Comp = asChild ? Slot : "div";
+
+ return (
+
+ );
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+ buttonGroupVariants,
+};
diff --git a/client/src/components/ui/button.tsx b/client/src/components/ui/button.tsx
new file mode 100644
index 0000000..6d74f9a
--- /dev/null
+++ b/client/src/components/ui/button.tsx
@@ -0,0 +1,60 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border bg-transparent shadow-xs hover:bg-accent dark:bg-transparent dark:border-input dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost:
+ "hover:bg-accent dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+);
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean;
+ }) {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/client/src/components/ui/calendar.tsx b/client/src/components/ui/calendar.tsx
new file mode 100644
index 0000000..48d4543
--- /dev/null
+++ b/client/src/components/ui/calendar.tsx
@@ -0,0 +1,211 @@
+import * as React from "react";
+import {
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+} from "lucide-react";
+import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
+
+import { cn } from "@/lib/utils";
+import { Button, buttonVariants } from "@/components/ui/button";
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = "label",
+ buttonVariant = "ghost",
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps["variant"];
+}) {
+ const defaultClassNames = getDefaultClassNames();
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className
+ )}
+ captionLayout={captionLayout}
+ formatters={{
+ formatMonthDropdown: date =>
+ date.toLocaleString("default", { month: "short" }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn("w-fit", defaultClassNames.root),
+ months: cn(
+ "flex gap-4 flex-col md:flex-row relative",
+ defaultClassNames.months
+ ),
+ month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
+ nav: cn(
+ "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
+ defaultClassNames.nav
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
+ defaultClassNames.button_previous
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
+ defaultClassNames.button_next
+ ),
+ month_caption: cn(
+ "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
+ defaultClassNames.month_caption
+ ),
+ dropdowns: cn(
+ "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
+ defaultClassNames.dropdowns
+ ),
+ dropdown_root: cn(
+ "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
+ defaultClassNames.dropdown_root
+ ),
+ dropdown: cn(
+ "absolute bg-popover inset-0 opacity-0",
+ defaultClassNames.dropdown
+ ),
+ caption_label: cn(
+ "select-none font-medium",
+ captionLayout === "label"
+ ? "text-sm"
+ : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
+ defaultClassNames.caption_label
+ ),
+ table: "w-full border-collapse",
+ weekdays: cn("flex", defaultClassNames.weekdays),
+ weekday: cn(
+ "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
+ defaultClassNames.weekday
+ ),
+ week: cn("flex w-full mt-2", defaultClassNames.week),
+ week_number_header: cn(
+ "select-none w-(--cell-size)",
+ defaultClassNames.week_number_header
+ ),
+ week_number: cn(
+ "text-[0.8rem] select-none text-muted-foreground",
+ defaultClassNames.week_number
+ ),
+ day: cn(
+ "relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
+ defaultClassNames.day
+ ),
+ range_start: cn(
+ "rounded-l-md bg-accent",
+ defaultClassNames.range_start
+ ),
+ range_middle: cn("rounded-none", defaultClassNames.range_middle),
+ range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
+ today: cn(
+ "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
+ defaultClassNames.today
+ ),
+ outside: cn(
+ "text-muted-foreground aria-selected:text-muted-foreground",
+ defaultClassNames.outside
+ ),
+ disabled: cn(
+ "text-muted-foreground opacity-50",
+ defaultClassNames.disabled
+ ),
+ hidden: cn("invisible", defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return (
+
+ );
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === "left") {
+ return (
+
+ );
+ }
+
+ if (orientation === "right") {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ },
+ DayButton: CalendarDayButton,
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+ |
+ );
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ );
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ ...props
+}: React.ComponentProps) {
+ const defaultClassNames = getDefaultClassNames();
+
+ const ref = React.useRef(null);
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus();
+ }, [modifiers.focused]);
+
+ return (
+