diff --git a/frontend/apps/artcraft-dashboard/.claude/CLAUDE.md b/frontend/apps/artcraft-dashboard/.claude/CLAUDE.md
new file mode 100644
index 0000000000..59af4f7a0c
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/.claude/CLAUDE.md
@@ -0,0 +1,60 @@
+## vexp - Context-Aware AI Coding
+
+### MANDATORY: use vexp pipeline - do NOT grep or glob the codebase
+For every task - bug fixes, features, refactors, debugging:
+**call `run_pipeline` FIRST**. It executes context search + impact analysis +
+memory recall in a single call, returning compressed results.
+
+Do NOT use grep, glob, Bash, or cat to search/explore the codebase.
+vexp returns pre-indexed, graph-ranked context that is more relevant and
+uses fewer tokens than manual searching. Prefer `get_skeleton` over Read to
+inspect files (detail: minimal/standard/detailed, 70-90% token savings).
+Only use Read when you need exact raw content to edit a specific line.
+
+### Primary Tool
+- `run_pipeline` - **USE THIS FOR EVERYTHING**. Single call that runs
+ capsule + impact + memory server-side. Returns compressed results.
+ Auto-detects intent (debug/modify/refactor/explore) from your task.
+ Includes full file content for pivots.
+ Examples:
+ - `run_pipeline({ "task": "fix JWT validation bug" })` - auto-detect
+ - `run_pipeline({ "task": "refactor db layer", "preset": "refactor" })` - explicit
+ - `run_pipeline({ "task": "add auth", "observation": "using JWT" })` - save insight in same call
+
+### Other MCP tools (use only when run_pipeline is insufficient)
+- `get_skeleton` - **preferred over Read** for inspecting files (minimal/standard/detailed detail levels, 70-90% token savings)
+- `index_status` - indexing status and health check
+- `expand_vexp_ref` - expand V-REF hash placeholders in v2 compact output
+
+### Workflow
+1. `run_pipeline("your task")` - ALWAYS FIRST. Returns pivots + impact + memories in 1 call
+2. Need more detail on a file? Use `get_skeleton({ files: [...], detail: "detailed" })` - avoid Read unless editing
+3. Make targeted changes based on the context returned
+4. `run_pipeline` again ONLY if you need more context during implementation
+5. Do NOT chain multiple vexp calls - one `run_pipeline` replaces capsule + impact + memory + observation
+
+### Subagent / Explore / Plan mode
+- Subagents CAN and MUST call `run_pipeline` - always include the task description
+- The PreToolUse hook blocks Grep/Glob when vexp daemon is running
+- Do NOT spawn Agent(Explore) to freely search - call `run_pipeline` first,
+ then pass the returned context into the agent prompt if needed
+- Always: `run_pipeline` -> get context -> spawn agent with context
+
+### Smart Features (automatic - no action needed)
+- **Intent Detection**: auto-detects from your task keywords. "fix bug" -> Debug, "refactor" -> blast-radius, "add" -> Modify
+- **Hybrid Search**: keyword + semantic + graph centrality ranking
+- **Session Memory**: auto-captures observations; memories auto-surfaced in results
+- **LSP Bridge**: VS Code captures type-resolved call edges
+- **Change Coupling**: co-changed files included as related context
+
+### Advanced Parameters
+- `preset: "debug"` - forces debug mode (capsule+tests+impact+memory)
+- `preset: "refactor"` - deep impact analysis (depth 5)
+- `max_tokens: 12000` - increase total budget for complex tasks
+- `include_tests: true` - include test files in results
+- `include_file_content: false` - omit full file content (lighter response)
+
+### Multi-Repo Workspaces
+`run_pipeline` auto-queries all indexed repos. Use `repos: ["alias"]` to scope.
+Use `index_status` to discover available repo aliases.
+
\ No newline at end of file
diff --git a/frontend/apps/artcraft-dashboard/.claude/hooks/vexp-guard.sh b/frontend/apps/artcraft-dashboard/.claude/hooks/vexp-guard.sh
new file mode 100644
index 0000000000..9cefc1dfcf
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/.claude/hooks/vexp-guard.sh
@@ -0,0 +1,14 @@
+#!/bin/bash
+# vexp-guard: block Grep/Glob when vexp daemon is running AND index is healthy.
+# Fast path: if socket file or healthy marker doesn't exist, allow immediately.
+# PID check: verify daemon process is alive (handles stale files after kill -9).
+VEXP_DIR="${CLAUDE_PROJECT_DIR:-.}/.vexp"
+SOCK="$VEXP_DIR/daemon.sock"
+HEALTHY="$VEXP_DIR/healthy"
+PID_FILE="$VEXP_DIR/daemon.pid"
+if [ -S "$SOCK" ] && [ -f "$HEALTHY" ] && [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE" 2>/dev/null)" 2>/dev/null; then
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"vexp daemon is running. Use run_pipeline instead of Grep/Glob."}}'
+else
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"vexp index not ready, allowing direct search fallback."}}'
+fi
+exit 0
diff --git a/frontend/apps/artcraft-dashboard/.claude/settings.json b/frontend/apps/artcraft-dashboard/.claude/settings.json
new file mode 100644
index 0000000000..3883ca63fe
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/.claude/settings.json
@@ -0,0 +1,16 @@
+{
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Grep|Glob|Regex",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/vexp-guard.sh",
+ "timeout": 3000
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/frontend/apps/artcraft-dashboard/.claude/settings.local.json b/frontend/apps/artcraft-dashboard/.claude/settings.local.json
new file mode 100644
index 0000000000..3d7789fb07
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/.claude/settings.local.json
@@ -0,0 +1,9 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(npx shadcn@latest add:*)",
+ "Bash(node -e:*)",
+ "WebFetch(domain:docs.gravatar.com)"
+ ]
+ }
+}
diff --git a/frontend/apps/artcraft-dashboard/.vscode/settings.json b/frontend/apps/artcraft-dashboard/.vscode/settings.json
new file mode 100644
index 0000000000..7fcaad65dc
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/.vscode/settings.json
@@ -0,0 +1,23 @@
+{
+ "files.exclude": {
+ "**/.git": true,
+ "**/.svn": true,
+ "**/.hg": true,
+ "**/.DS_Store": true,
+ "**/Thumbs.db": true,
+ "**/.vexp/daemon.pid": true,
+ "**/.vexp/daemon.sock": true,
+ "**/.vexp/daemon.pipe": true,
+ "**/.vexp/daemon.log": true,
+ "**/.vexp/vexp.log": true,
+ "**/.vexp/healthy": true,
+ "**/.vexp/mcp.port": true,
+ "**/.vexp/mcp.token": true,
+ "**/.vexp/index.db": true,
+ "**/.vexp/index.db-wal": true,
+ "**/.vexp/index.db-shm": true,
+ "**/.vexp/parent_workspace.json": true,
+ "**/.vexp/.gitignore": true,
+ "**/.vexp/.gitattributes": true
+ }
+}
\ No newline at end of file
diff --git a/frontend/apps/artcraft-dashboard/README.md b/frontend/apps/artcraft-dashboard/README.md
new file mode 100644
index 0000000000..9e8a61708e
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/README.md
@@ -0,0 +1,2 @@
+# ArtCraft Admin Dashboard
+Admin dashboard for managing ArtCraft
diff --git a/frontend/apps/artcraft-dashboard/_redirects b/frontend/apps/artcraft-dashboard/_redirects
new file mode 100644
index 0000000000..b06d6fa10f
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/_redirects
@@ -0,0 +1,41 @@
+# Netlify redirects for the ArtCraft admin dashboard.
+# Rules are evaluated top-to-bottom; the first match wins, so the SPA
+# fallback MUST stay last.
+
+# API proxy — mirrors the dev Vite proxy in vite.config.ts. The app's
+# ApiManager defaults its host to "/api" (see src/api/ApiManager.ts), so
+# in production "/api/v1/foo" must be forwarded to the real API as
+# "/v1/foo". status 200 = proxy (no client-visible redirect).
+/api/* https://api.storyteller.ai/:splat 200
+
+# SPA fallback — this is a client-side React Router app (BrowserRouter in
+# src/App.tsx). Every page below is rendered client-side, so every deep
+# link (including dynamic params) must serve index.html and let the router
+# take over. A per-page list is unnecessary and would break param routes;
+# the single catch-all covers them all:
+#
+# /login
+# / (DashboardHome)
+# /user/search
+# /user/profile/:username
+# /user/profile/:username/wallet
+# /user/profile/:username/jobs
+# /user/profile/:username/creations
+# /user/profile/:username/referrals
+# /stripe-lookup
+# /user-signups
+# /subscriber-signups
+# /impersonation
+# /staff-audit-logs
+# /send-pager
+# /feature-flags
+# /email-changes
+# /referrals
+# /explore/media
+# /explore/media/:mediaToken
+# /moderation/job-search
+# /moderation/job/:jobToken
+# /moderation/debug-logs-search
+# /moderation/debug-logs/:eventToken
+#
+/* /index.html 200
diff --git a/frontend/apps/artcraft-dashboard/components.json b/frontend/apps/artcraft-dashboard/components.json
new file mode 100644
index 0000000000..2a9908f264
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/components.json
@@ -0,0 +1,24 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/index.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "rtl": false,
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks",
+ "context": "@/context"
+ },
+ "registries": {}
+}
diff --git a/frontend/apps/artcraft-dashboard/eslint.config.js b/frontend/apps/artcraft-dashboard/eslint.config.js
new file mode 100644
index 0000000000..5e6b472f58
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/eslint.config.js
@@ -0,0 +1,23 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import tseslint from 'typescript-eslint'
+import { defineConfig, globalIgnores } from 'eslint/config'
+
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ js.configs.recommended,
+ tseslint.configs.recommended,
+ reactHooks.configs.flat.recommended,
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ },
+ },
+])
diff --git a/frontend/apps/artcraft-dashboard/index.html b/frontend/apps/artcraft-dashboard/index.html
new file mode 100644
index 0000000000..1b06962dd0
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+ ArtCraft Admin Dashboard
+
+
+
+
+
+
diff --git a/frontend/apps/artcraft-dashboard/netlify.toml b/frontend/apps/artcraft-dashboard/netlify.toml
new file mode 100644
index 0000000000..4fa3bad536
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/netlify.toml
@@ -0,0 +1,15 @@
+# Netlify build configuration for the ArtCraft admin dashboard.
+# The base directory in the Netlify site settings should be `frontend/`
+# (the Nx workspace root), so these paths are relative to that.
+
+[build]
+command = "bash ./apps/artcraft-dashboard/script/netlify_build.sh"
+publish = "apps/artcraft-dashboard/dist/"
+
+# Vite emits content-hashed assets under /assets/*, so they can be cached
+# forever — a new deploy produces new filenames.
+[[headers]]
+for = "/assets/*"
+
+[headers.values]
+"Cache-Control" = "public, max-age=31560000, immutable"
diff --git a/frontend/apps/artcraft-dashboard/package.json b/frontend/apps/artcraft-dashboard/package.json
new file mode 100644
index 0000000000..a783990955
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "@frontend/artcraft-dashboard",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "@dicebear/collection": "^9.4.0",
+ "@dicebear/core": "^9.4.0",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/modifiers": "^9.0.0",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
+ "@tabler/icons-react": "^3.38.0",
+ "@tailwindcss/vite": "^4.2.1",
+ "@tanstack/react-table": "^8.21.3",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "country-flag-icons": "^1.6.15",
+ "lucide-react": "^0.577.0",
+ "next-themes": "^0.4.6",
+ "radix-ui": "^1.4.3",
+ "react-router-dom": "^7.13.1",
+ "recharts": "2.15.4",
+ "sonner": "^2.0.7",
+ "tailwind-merge": "^3.5.0",
+ "tailwindcss": "^4.2.1",
+ "vaul": "^1.1.2",
+ "zod": "^4.3.6"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.39.1",
+ "@types/node": "^24.11.0",
+ "@vitejs/plugin-react-swc": "^4.2.2",
+ "eslint": "^9.39.1",
+ "eslint-plugin-react-hooks": "^7.0.1",
+ "eslint-plugin-react-refresh": "^0.4.24",
+ "globals": "^16.5.0",
+ "shadcn": "^3.8.5",
+ "tw-animate-css": "^1.4.0",
+ "typescript-eslint": "^8.48.0"
+ }
+}
diff --git a/frontend/apps/artcraft-dashboard/public/_redirects b/frontend/apps/artcraft-dashboard/public/_redirects
new file mode 100644
index 0000000000..5192543dd7
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/public/_redirects
@@ -0,0 +1,2 @@
+/api/* https://api.storyteller.ai/:splat 200
+/* /index.html 200
diff --git a/frontend/apps/artcraft-dashboard/public/artcraft-icon.svg b/frontend/apps/artcraft-dashboard/public/artcraft-icon.svg
new file mode 100644
index 0000000000..efc0be9e7f
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/public/artcraft-icon.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/apps/artcraft-dashboard/script/netlify_build.sh b/frontend/apps/artcraft-dashboard/script/netlify_build.sh
new file mode 100644
index 0000000000..683b93042b
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/script/netlify_build.sh
@@ -0,0 +1,17 @@
+#!/bin/bash
+
+set -euxo pipefail
+
+echo "Build the dashboard"
+npx nx build artcraft-dashboard
+
+echo "Change to project dir"
+pushd apps/artcraft-dashboard/
+
+echo "Copy netlify configs into dist"
+cp _redirects dist/
+
+echo "List final files in build"
+find dist/
+
+popd
diff --git a/frontend/apps/artcraft-dashboard/src/App.css b/frontend/apps/artcraft-dashboard/src/App.css
new file mode 100644
index 0000000000..b9d355df2a
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/App.css
@@ -0,0 +1,42 @@
+#root {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 2rem;
+ text-align: center;
+}
+
+.logo {
+ height: 6em;
+ padding: 1.5em;
+ will-change: filter;
+ transition: filter 300ms;
+}
+.logo:hover {
+ filter: drop-shadow(0 0 2em #646cffaa);
+}
+.logo.react:hover {
+ filter: drop-shadow(0 0 2em #61dafbaa);
+}
+
+@keyframes logo-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ a:nth-of-type(2) .logo {
+ animation: logo-spin infinite 20s linear;
+ }
+}
+
+.card {
+ padding: 2em;
+}
+
+.read-the-docs {
+ color: #888;
+}
diff --git a/frontend/apps/artcraft-dashboard/src/App.tsx b/frontend/apps/artcraft-dashboard/src/App.tsx
new file mode 100644
index 0000000000..3fcd83ea00
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/App.tsx
@@ -0,0 +1,103 @@
+import { BrowserRouter, Routes, Route, Navigate, useLocation, useSearchParams } from "react-router-dom";
+import { AuthProvider, useAuth } from "@/context/AuthContext";
+import { DashboardLayout } from "@/layouts/DashboardLayout";
+import { LoginPage } from "@/pages/Login";
+import { DashboardHome } from "@/pages/DashboardHome";
+import { UserSearch } from "@/pages/UserSearch";
+import { UserProfile } from "@/pages/UserProfile";
+import { WalletHistory } from "@/pages/WalletHistory";
+import { StripeLookup } from "@/pages/StripeLookup";
+import { JobHistory } from "@/pages/JobHistory";
+import { UserCreations } from "@/pages/UserCreations";
+import { UserSignups } from "@/pages/UserSignups";
+import { SubscriberSignups } from "@/pages/SubscriberSignups";
+import { ExploreMedia } from "@/pages/ExploreMedia";
+import { Impersonation } from "@/pages/Impersonation";
+import { StaffAuditLogs } from "@/pages/StaffAuditLogs";
+import { SendPager } from "@/pages/SendPager";
+import { FeatureFlags } from "@/pages/FeatureFlags";
+import { UserEmailChanges } from "@/pages/UserEmailChanges";
+import { ReferralsAnalytics } from "@/pages/ReferralsAnalytics";
+import { UserReferrals } from "@/pages/UserReferrals";
+import { JobInfo } from "@/pages/JobInfo";
+import { JobTokenSearch } from "@/pages/JobTokenSearch";
+import { DebugLogs } from "@/pages/DebugLogs";
+import { DebugLogsSearch } from "@/pages/DebugLogsSearch";
+import { NotFoundPage } from "@/pages/NotFound";
+import { Spinner } from "./components/ui/spinner";
+import { ThemeProvider } from "@/components/theme-provider";
+
+function AppRoutes() {
+ const { user, isLoading } = useAuth();
+ const location = useLocation();
+ const [searchParams] = useSearchParams();
+ const redirectTo = searchParams.get("redirect") || "/";
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ : }
+ />
+
+ : }
+ >
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ }
+ />
+ }
+ />
+
+
+ {/* 404 */}
+ } />
+
+ );
+}
+
+function App() {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export default App;
diff --git a/frontend/apps/artcraft-dashboard/src/api/ApiManager.ts b/frontend/apps/artcraft-dashboard/src/api/ApiManager.ts
new file mode 100644
index 0000000000..4551637ad6
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/api/ApiManager.ts
@@ -0,0 +1,124 @@
+export interface ApiResponse {
+ success: boolean;
+ errorMessage?: string;
+ data?: T;
+ pagination?: P;
+}
+
+const getApiHost = () => {
+ return import.meta.env.VITE_API_HOST || "/api";
+};
+
+export class ApiManager {
+ protected getApiSchemeAndHost(): string {
+ return getApiHost();
+ }
+
+ public async fetch(
+ endpoint: string,
+ {
+ method,
+ query,
+ body,
+ }: {
+ method: string;
+ query?: Record;
+ body?: B;
+ },
+ ): Promise {
+ const queryInString =
+ query &&
+ Object.entries(query).reduce(
+ (allOptions, [key, value]) => {
+ if (!value) {
+ return allOptions;
+ }
+ allOptions[key] = value.toString();
+ return allOptions;
+ },
+ {} as Record,
+ );
+
+ const endpointWithQueries = queryInString
+ ? endpoint + "?" + new URLSearchParams(queryInString)
+ : endpoint;
+
+ const bodyInString = body ? JSON.stringify(body) : undefined;
+
+ const response = await fetch(endpointWithQueries, {
+ method,
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ credentials: "include",
+ body: bodyInString,
+ });
+
+ const text = await response.text();
+ try {
+ return JSON.parse(text);
+ } catch {
+ console.error("API Error Response:", text);
+ throw new Error(text || `HTTP ${response.status}`);
+ }
+ }
+
+ protected get({
+ endpoint,
+ query,
+ }: {
+ endpoint: string;
+ query?: Record;
+ }): Promise {
+ return this.fetch(endpoint, { method: "GET", query });
+ }
+
+ protected post({
+ endpoint,
+ query,
+ body,
+ }: {
+ endpoint: string;
+ query?: Record;
+ body?: B;
+ }): Promise {
+ return this.fetch(endpoint, {
+ method: "POST",
+ query,
+ body,
+ });
+ }
+
+ protected put({
+ endpoint,
+ query,
+ body,
+ }: {
+ endpoint: string;
+ query?: Record;
+ body?: B;
+ }): Promise {
+ return this.fetch(endpoint, {
+ method: "PUT",
+ query,
+ body,
+ });
+ }
+
+ protected delete({
+ endpoint,
+ query,
+ body,
+ }: {
+ endpoint: string;
+ query?: Record;
+ body?: B;
+ }): Promise {
+ return this.fetch(endpoint, {
+ method: "DELETE",
+ query,
+ body,
+ });
+ }
+}
diff --git a/frontend/apps/artcraft-dashboard/src/api/MediaApi.ts b/frontend/apps/artcraft-dashboard/src/api/MediaApi.ts
new file mode 100644
index 0000000000..9b3037e48c
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/api/MediaApi.ts
@@ -0,0 +1,305 @@
+import { ApiManager, type ApiResponse } from "./ApiManager";
+
+export interface Pagination {
+ total_items: number;
+ total_pages: number;
+ current_page: number;
+ items_per_page: number;
+}
+
+export interface ListUserMediaQuery {
+ page_index?: number;
+ page_size?: number;
+ filter_media_classes?: string[];
+ filter_media_type?: string[];
+ filter_engine_categories?: string[];
+}
+
+export interface ListMediaFilesQuery {
+ sort_ascending?: boolean;
+ page_size?: number;
+ cursor?: string;
+ cursor_is_reversed?: boolean;
+ filter_media_classes?: string;
+ filter_media_type?: string;
+ filter_engine_categories?: string;
+ include_user_uploads?: boolean;
+}
+
+export interface CursorPagination {
+ cursor_is_reversed: boolean;
+ maybe_next: string | null;
+ maybe_previous: string | null;
+}
+
+export interface ExploreMediaFile {
+ token: string;
+ media_class: string;
+ media_type: string;
+ created_at: string;
+ updated_at: string;
+ maybe_title: string | null;
+ maybe_text_transcript: string | null;
+ creator_set_visibility: string;
+ is_user_upload: boolean;
+ is_intermediate_system_file: boolean;
+ media_links: {
+ cdn_url: string;
+ maybe_thumbnail_template: string | null;
+ maybe_video_previews: {
+ animated?: string;
+ thumbnail_template?: string;
+ } | null;
+ };
+ cover_image: {
+ default_cover: {
+ color_index: number;
+ image_index: number;
+ };
+ maybe_cover_image_public_bucket_url: string | null;
+ maybe_links: {
+ cdn_url: string;
+ maybe_thumbnail_template: string | null;
+ } | null;
+ };
+ maybe_creator: {
+ default_avatar: { color_index: number; image_index: number };
+ display_name: string;
+ gravatar_hash: string;
+ user_token: string;
+ username: string;
+ } | null;
+ maybe_duration_millis: number | null;
+ maybe_engine_category: string | null;
+ origin: {
+ origin_category: string;
+ product_category: string;
+ };
+ stats: {
+ bookmark_count: number;
+ positive_rating_count: number;
+ };
+}
+
+export interface MediaInfo {
+ token: string;
+ media_class: string;
+ media_type: string;
+ public_bucket_url?: string;
+ media_links?: {
+ cdn_url?: string;
+ maybe_thumbnail_template?: string;
+ maybe_video_previews?: {
+ animated?: string;
+ thumbnail_template?: string;
+ } | null;
+ };
+ maybe_title?: string;
+ maybe_text_transcript?: string;
+ created_at?: string;
+ updated_at?: string;
+ creator_set_visibility?: string;
+ // include arbitrary keys
+ [key: string]: any;
+}
+
+export function getMediaThumbnailUrl(
+ item: MediaInfo | ExploreMediaFile,
+ width = 512,
+): string | undefined {
+ const widthStr = width.toString();
+
+ // Videos: use animated preview
+ if (item.media_class === "video") {
+ if (item.media_links?.maybe_video_previews?.animated) {
+ return item.media_links.maybe_video_previews.animated;
+ }
+ }
+
+ // 3D/dimensional: use cover image URL
+ if (item.media_class === "dimensional") {
+ if ("cover_image" in item && item.cover_image) {
+ if (item.cover_image.maybe_cover_image_public_bucket_url) {
+ return item.cover_image.maybe_cover_image_public_bucket_url;
+ }
+ if (item.cover_image.maybe_links?.maybe_thumbnail_template) {
+ return item.cover_image.maybe_links.maybe_thumbnail_template.replace(
+ "{WIDTH}",
+ widthStr,
+ );
+ }
+ if (item.cover_image.maybe_links?.cdn_url) {
+ return item.cover_image.maybe_links.cdn_url;
+ }
+ }
+ }
+
+ // Images (and fallback): use thumbnail template
+ if (item.media_links?.maybe_thumbnail_template) {
+ return item.media_links.maybe_thumbnail_template.replace(
+ "{WIDTH}",
+ widthStr,
+ );
+ }
+
+ if (item.media_links?.cdn_url) return item.media_links.cdn_url;
+ if ("public_bucket_url" in item && item.public_bucket_url)
+ return item.public_bucket_url as string;
+ return undefined;
+}
+
+export interface MediaFileDetail {
+ token: string;
+ maybe_prompt_token: string | null;
+ media_class: string;
+ media_type: string;
+ maybe_title: string | null;
+ maybe_text_transcript: string | null;
+ [key: string]: any;
+}
+
+export interface PromptInfo {
+ token: string;
+ maybe_model_type: string | null;
+ maybe_model_class: string | null;
+ maybe_positive_prompt: string | null;
+ maybe_negative_prompt: string | null;
+ maybe_aspect_ratio: string | null;
+ maybe_resolution: string | null;
+ maybe_generation_provider: string | null;
+ maybe_generation_mode: string | null;
+ maybe_duration_seconds: number | null;
+ maybe_inference_duration_millis: number | null;
+}
+
+export class MediaApi extends ApiManager {
+ public async ListMediaFiles(
+ query: ListMediaFilesQuery = {},
+ ): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/list`;
+
+ const queryParams: Record = {};
+
+ if (query.page_size !== undefined) queryParams.page_size = query.page_size;
+ if (query.cursor) queryParams.cursor = query.cursor;
+ if (query.sort_ascending !== undefined)
+ queryParams.sort_ascending = query.sort_ascending;
+ if (query.cursor_is_reversed !== undefined)
+ queryParams.cursor_is_reversed = query.cursor_is_reversed;
+ if (query.filter_media_classes)
+ queryParams.filter_media_classes = query.filter_media_classes;
+ if (query.filter_media_type)
+ queryParams.filter_media_type = query.filter_media_type;
+ if (query.filter_engine_categories)
+ queryParams.filter_engine_categories = query.filter_engine_categories;
+ if (query.include_user_uploads)
+ queryParams.include_user_uploads = query.include_user_uploads;
+
+ return await this.get<{
+ success: boolean;
+ results: ExploreMediaFile[];
+ pagination: CursorPagination;
+ error_message?: string;
+ }>({ endpoint, query: queryParams })
+ .then((response) => ({
+ success: response.success,
+ data: response.results ?? [],
+ pagination: response.pagination,
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async ListUserMediaFiles(
+ username: string,
+ query: ListUserMediaQuery = {},
+ ): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/list/user/${username}`;
+
+ // Convert to fully stringified dict
+ const queryWithStrings: Record = {
+ include_user_uploads: true,
+ };
+
+ if (query.page_index !== undefined)
+ queryWithStrings.page_index = query.page_index;
+ if (query.page_size !== undefined)
+ queryWithStrings.page_size = query.page_size;
+
+ if (query.filter_media_classes) {
+ queryWithStrings.filter_media_classes =
+ query.filter_media_classes.join(",");
+ }
+ if (query.filter_media_type) {
+ queryWithStrings.filter_media_type = query.filter_media_type.join(",");
+ }
+ if (query.filter_engine_categories) {
+ queryWithStrings.filter_engine_categories =
+ query.filter_engine_categories.join(",");
+ }
+
+ return await this.get<{
+ success: boolean;
+ results: MediaInfo[];
+ pagination?: Pagination;
+ error_message?: string;
+ }>({ endpoint, query: queryWithStrings })
+ .then((response) => ({
+ success: response.success,
+ data: response.results ?? [],
+ pagination: response.pagination,
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => {
+ return {
+ success: false,
+ errorMessage: err.message,
+ };
+ });
+ }
+
+ public async GetMediaFile(
+ token: string,
+ ): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/file/${token}`;
+
+ return await this.get<{
+ success: boolean;
+ media_file: MediaFileDetail;
+ error_message?: string;
+ }>({ endpoint })
+ .then((response) => ({
+ success: response.success,
+ data: response.media_file,
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async GetPrompt(
+ token: string,
+ ): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/prompts/${token}`;
+
+ return await this.get<{
+ success: boolean;
+ prompt: PromptInfo;
+ error_message?: string;
+ }>({ endpoint })
+ .then((response) => ({
+ success: response.success,
+ data: response.prompt,
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+}
diff --git a/frontend/apps/artcraft-dashboard/src/api/ModerationApi.ts b/frontend/apps/artcraft-dashboard/src/api/ModerationApi.ts
new file mode 100644
index 0000000000..23e94f082d
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/api/ModerationApi.ts
@@ -0,0 +1,798 @@
+import { ApiManager, type ApiResponse } from "./ApiManager";
+import type {
+ Wallet,
+ WalletWithOwner,
+ WalletLedgerEntry,
+ WalletLedgerEntryWithWallet,
+ UserJob,
+ SignupUser,
+ SubscriberUser,
+ ImpersonationRequest,
+ StaffAuditLog,
+ ModerationJob,
+ DebugLog,
+ UserEmailChange,
+} from "@/types";
+
+export interface FeatureFlagDescriptor {
+ key: string;
+ full_name: string;
+ description: string;
+}
+
+export interface UserFeatureFlagsResponse {
+ user_token: string;
+ username: string;
+ display_name: string;
+ feature_flags: string[];
+}
+
+export type EditUserFeatureFlagsAction =
+ | { AddFlags: { flags: string[] } }
+ | { RemoveFlags: { flags: string[] } }
+ | { KeepFlags: { flags: string[] } }
+ | { SetExactFlags: { flags: string[] } }
+ | "ClearAllFlags";
+
+export interface UserReferralInvitedUser {
+ token: string;
+ username: string;
+ display_name: string;
+ email_address: string;
+}
+
+export interface UserReferralReferrerUser {
+ token: string;
+ username: string;
+ display_name: string;
+}
+
+export interface UserReferralListItem {
+ created_at: string;
+ invited_user: UserReferralInvitedUser;
+ referrer_user: UserReferralReferrerUser;
+ maybe_landing_url: string | null;
+ maybe_referral_url: string | null;
+ maybe_referral_code_token: string | null;
+}
+
+/** Which table the customer id was found in. */
+export type ModeratorStripeCustomerIdSource = "customer_link" | "subscription";
+
+export interface ModeratorUserStripeCustomerIdEntry {
+ stripe_customer_id: string;
+ payments_namespace: string;
+ source: ModeratorStripeCustomerIdSource;
+}
+
+export interface ModeratorUserLookupResponse {
+ display_name: string;
+ email_address: string;
+ email_confirmed: boolean;
+ email_gravatar_hash: string;
+ email_is_synthetic: boolean;
+ ip_address_creation: string;
+ ip_address_last_login: string;
+ is_banned: boolean;
+ is_temporary: boolean;
+ is_without_password: boolean;
+ maybe_avatar_media_file_token?: string;
+ created_at?: string;
+ token: string;
+ username: string;
+ username_is_generated: boolean;
+ username_is_not_customized: boolean;
+}
+
+export class ModerationApi extends ApiManager {
+ public async UserLookup(
+ search: string,
+ ): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/users/lookup`;
+ return await this.post<
+ { search: string },
+ {
+ success: boolean;
+ maybe_user?: ModeratorUserLookupResponse;
+ error_message?: string;
+ }
+ >({
+ endpoint,
+ body: { search },
+ })
+ .then((response) => ({
+ success: response.success,
+ data: {
+ maybe_user: response.maybe_user,
+ },
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async LookupByStripeCustomerId(stripe_customer_id: string): Promise<
+ ApiResponse<{
+ users: Array<{
+ display_name: string;
+ email_address: string;
+ maybe_stripe_subscription_id?: string | null;
+ subscription_namespace: string;
+ token: string;
+ username: string;
+ }>;
+ }>
+ > {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/users/lookup_by_stripe_customer_id`;
+ return await this.post<
+ { stripe_customer_id: string },
+ {
+ success: boolean;
+ users: Array<{
+ display_name: string;
+ email_address: string;
+ maybe_stripe_subscription_id?: string | null;
+ subscription_namespace: string;
+ token: string;
+ username: string;
+ }>;
+ error_message?: string;
+ }
+ >({
+ endpoint,
+ body: { stripe_customer_id },
+ })
+ .then((response) => ({
+ success: response.success,
+ data: {
+ users: response.users || [],
+ },
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async GetUserStripeCustomerIds(
+ user_token: string,
+ ): Promise<
+ ApiResponse<{ customer_ids: ModeratorUserStripeCustomerIdEntry[] }>
+ > {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_stripe_data/${encodeURIComponent(user_token)}/customer_ids`;
+ return await this.get<{
+ success: boolean;
+ customer_ids: ModeratorUserStripeCustomerIdEntry[];
+ error_message?: string;
+ }>({ endpoint })
+ .then((response) => ({
+ success: response.success,
+ data: { customer_ids: response.customer_ids || [] },
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async ImpersonateUser(params: {
+ username?: string;
+ user_token?: string;
+ email_address?: string;
+ username_email_or_token?: string;
+ }): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_sessions/impersonate`;
+ return await this.post<
+ typeof params,
+ {
+ success: boolean;
+ password_token?: string;
+ error_message?: string;
+ }
+ >({
+ endpoint,
+ body: params,
+ })
+ .then((response) => ({
+ success: response.success,
+ data: response.password_token
+ ? { password_token: response.password_token }
+ : undefined,
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async SendAlert(params: {
+ title?: string | null;
+ description?: string | null;
+ urgency?: "high" | "medium" | "low" | null;
+ }): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/alerts/send`;
+ return await this.post<
+ typeof params,
+ { success: boolean; error_message?: string }
+ >({ endpoint, body: params })
+ .then((response) => ({
+ success: response.success,
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async ManageUserBan(params: {
+ username: string;
+ is_banned: boolean;
+ mod_notes: string;
+ }): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/moderation/user_bans/manage_ban`;
+ return await this.post<
+ typeof params,
+ { success: boolean; error_message?: string }
+ >({ endpoint, body: params })
+ .then((response) => ({
+ success: response.success,
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async ListStaffAuditLogs(
+ cursor?: string | null,
+ limit?: number,
+ ): Promise<
+ ApiResponse<{
+ audit_logs: StaffAuditLog[];
+ next_cursor: string | null;
+ }>
+ > {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/staff_audit_logs/list`;
+ return await this.get<{
+ success: boolean;
+ audit_logs: StaffAuditLog[];
+ maybe_cursor: string | null;
+ error_message?: string;
+ }>({
+ endpoint,
+ query: {
+ cursor: cursor ?? undefined,
+ limit,
+ },
+ })
+ .then((response) => ({
+ success: response.success,
+ data: {
+ audit_logs: response.audit_logs || [],
+ next_cursor: response.maybe_cursor ?? null,
+ },
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async ListAllImpersonationRequests(
+ cursor?: string | null,
+ limit?: number,
+ ): Promise<
+ ApiResponse<{
+ impersonation_requests: ImpersonationRequest[];
+ next_cursor: string | null;
+ }>
+ > {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_sessions/impersonation_requests/list`;
+ return await this.get<{
+ success: boolean;
+ impersonation_requests: ImpersonationRequest[];
+ maybe_cursor: string | null;
+ error_message?: string;
+ }>({
+ endpoint,
+ query: {
+ cursor: cursor ?? undefined,
+ limit,
+ },
+ })
+ .then((response) => ({
+ success: response.success,
+ data: {
+ impersonation_requests: response.impersonation_requests || [],
+ next_cursor: response.maybe_cursor ?? null,
+ },
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async ListUserImpersonationRequests(
+ user_token: string,
+ cursor?: string | null,
+ limit?: number,
+ ): Promise<
+ ApiResponse<{
+ impersonation_requests: ImpersonationRequest[];
+ next_cursor: string | null;
+ }>
+ > {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_sessions/impersonation_requests/user/${user_token}`;
+ return await this.get<{
+ success: boolean;
+ impersonation_requests: ImpersonationRequest[];
+ maybe_cursor: string | null;
+ error_message?: string;
+ }>({
+ endpoint,
+ query: {
+ cursor: cursor ?? undefined,
+ limit,
+ },
+ })
+ .then((response) => ({
+ success: response.success,
+ data: {
+ impersonation_requests: response.impersonation_requests || [],
+ next_cursor: response.maybe_cursor ?? null,
+ },
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async ListUserWallets(
+ user_token: string,
+ ): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/wallets/user/${user_token}/list`;
+ return await this.get<{
+ success: boolean;
+ wallets: Wallet[];
+ error_message?: string;
+ }>({ endpoint })
+ .then((response) => ({
+ success: response.success,
+ data: { wallets: response.wallets || [] },
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async GetWallet(
+ wallet_token: string,
+ ): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/wallet/${wallet_token}`;
+ return await this.get<{
+ success: boolean;
+ maybe_wallet: WalletWithOwner | null;
+ error_message?: string;
+ }>({ endpoint })
+ .then((response) => ({
+ success: response.success,
+ data: { maybe_wallet: response.maybe_wallet },
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async AddBankedBalance(
+ wallet_token: string,
+ credits: number,
+ ): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/wallet/${wallet_token}/add_banked_balance`;
+ return await this.post<
+ { credits: number },
+ { success: boolean; error_message?: string }
+ >({ endpoint, body: { credits } })
+ .then((response) => ({
+ success: response.success,
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async ListWalletLedgerEntries(
+ wallet_token: string,
+ ): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/wallet_ledger_entries/wallet/${wallet_token}/list`;
+ return await this.get<{
+ success: boolean;
+ entries: WalletLedgerEntryWithWallet[];
+ error_message?: string;
+ }>({ endpoint })
+ .then((response) => ({
+ success: response.success,
+ data: { entries: response.entries || [] },
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async GetWalletLedgerEntry(
+ wallet_ledger_entry_token: string,
+ ): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/wallet_ledger_entry/${wallet_ledger_entry_token}`;
+ return await this.get<{
+ success: boolean;
+ maybe_entry: WalletLedgerEntry | null;
+ error_message?: string;
+ }>({ endpoint })
+ .then((response) => ({
+ success: response.success,
+ data: { maybe_entry: response.maybe_entry },
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async CreateWalletForUser(
+ user_token: string,
+ payments_namespace?: string,
+ ): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/wallet/create_for_user`;
+ return await this.post<
+ { user_token: string; payments_namespace?: string },
+ { success: boolean; error_message?: string }
+ >({
+ endpoint,
+ body: { user_token, payments_namespace },
+ })
+ .then((response) => ({
+ success: response.success,
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async GetJobByToken(
+ job_token: string,
+ ): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/job/${encodeURIComponent(job_token)}`;
+ return await this.get<{
+ success: boolean;
+ job?: ModerationJob | null;
+ error_message?: string;
+ }>({ endpoint })
+ .then((response) => ({
+ success: response.success,
+ data: { maybe_job: response.job ?? null },
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async ListDebugLogs(
+ event_token: string,
+ limit?: number,
+ ): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/debug_logs/list/${encodeURIComponent(event_token)}`;
+ return await this.get<{
+ success: boolean;
+ debug_logs: DebugLog[];
+ error_message?: string;
+ }>({
+ endpoint,
+ query: { limit },
+ })
+ .then((response) => ({
+ success: response.success,
+ data: { debug_logs: response.debug_logs || [] },
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async ListUserJobs(
+ user_token: string,
+ ): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/jobs/user/${user_token}/list`;
+ return await this.get<{
+ success: boolean;
+ jobs: UserJob[];
+ error_message?: string;
+ }>({ endpoint })
+ .then((response) => ({
+ success: response.success,
+ data: { jobs: response.jobs || [] },
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async ListAllUsersBySignupDate(
+ id_cursor?: number | null,
+ ): Promise<
+ ApiResponse<{ users: SignupUser[]; next_cursor: number | null }>
+ > {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/users/list_all_by_signup_date`;
+ return await this.post<
+ { id_cursor?: number | null },
+ {
+ success: boolean;
+ users: SignupUser[];
+ next_cursor: number | null;
+ error_message?: string;
+ }
+ >({
+ endpoint,
+ body: { id_cursor: id_cursor ?? null },
+ })
+ .then((response) => ({
+ success: response.success,
+ data: {
+ users: response.users || [],
+ next_cursor: response.next_cursor ?? null,
+ },
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async ListAvailableFeatureFlags(): Promise<
+ ApiResponse<{ feature_flags: FeatureFlagDescriptor[] }>
+ > {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_feature_flags/list`;
+ return await this.get<{
+ success: boolean;
+ feature_flags: FeatureFlagDescriptor[];
+ error_message?: string;
+ }>({ endpoint })
+ .then((response) => ({
+ success: response.success,
+ data: { feature_flags: response.feature_flags || [] },
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async ListUserFeatureFlags(
+ username_or_token: string,
+ ): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_feature_flags/user/${encodeURIComponent(username_or_token)}`;
+ return await this.get<
+ { success: boolean; error_message?: string } & UserFeatureFlagsResponse
+ >({ endpoint })
+ .then((response) => ({
+ success: response.success,
+ data: {
+ user_token: response.user_token,
+ username: response.username,
+ display_name: response.display_name,
+ feature_flags: response.feature_flags || [],
+ },
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async EditUserFeatureFlags(
+ username_or_token: string,
+ action: EditUserFeatureFlagsAction,
+ ): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_feature_flags/user/${encodeURIComponent(username_or_token)}`;
+ return await this.post<
+ { action: EditUserFeatureFlagsAction },
+ { success: boolean; error_message?: string }
+ >({ endpoint, body: { action } })
+ .then((response) => ({
+ success: response.success,
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async ListGlobalUserReferrals(
+ cursor?: string | null,
+ limit?: number,
+ ): Promise<
+ ApiResponse<{
+ referrals: UserReferralListItem[];
+ next_cursor: string | null;
+ }>
+ > {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_referrals/list`;
+ return await this.get<{
+ success: boolean;
+ referrals: UserReferralListItem[];
+ maybe_cursor: string | null;
+ error_message?: string;
+ }>({
+ endpoint,
+ query: {
+ cursor: cursor ?? undefined,
+ limit,
+ },
+ })
+ .then((response) => ({
+ success: response.success,
+ data: {
+ referrals: response.referrals || [],
+ next_cursor: response.maybe_cursor ?? null,
+ },
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async ListUserReferralsForUser(
+ username: string,
+ cursor?: string | null,
+ limit?: number,
+ ): Promise<
+ ApiResponse<{
+ referrals: UserReferralListItem[];
+ next_cursor: string | null;
+ }>
+ > {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/user_referrals/user/${encodeURIComponent(username)}/list`;
+ return await this.get<{
+ success: boolean;
+ referrals: UserReferralListItem[];
+ maybe_cursor: string | null;
+ error_message?: string;
+ }>({
+ endpoint,
+ query: {
+ cursor: cursor ?? undefined,
+ limit,
+ },
+ })
+ .then((response) => ({
+ success: response.success,
+ data: {
+ referrals: response.referrals || [],
+ next_cursor: response.maybe_cursor ?? null,
+ },
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async ListSubscribersBySignupDate(
+ id_cursor?: number | null,
+ ): Promise<
+ ApiResponse<{ users: SubscriberUser[]; next_cursor: number | null }>
+ > {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/moderation/users/list_subscribers_by_signup_date`;
+ return await this.post<
+ { id_cursor?: number | null },
+ {
+ success: boolean;
+ users: SubscriberUser[];
+ next_cursor: number | null;
+ error_message?: string;
+ }
+ >({
+ endpoint,
+ body: { id_cursor: id_cursor ?? null },
+ })
+ .then((response) => ({
+ success: response.success,
+ data: {
+ users: response.users || [],
+ next_cursor: response.next_cursor ?? null,
+ },
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async ChangeUserEmail(
+ user_token: string,
+ new_email: string,
+ ): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/moderation/user_emails/change`;
+ return await this.post<
+ { user_token: string; new_email: string },
+ { success: boolean; error_message?: string }
+ >({ endpoint, body: { user_token, new_email } })
+ .then((response) => ({
+ success: response.success,
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async ListUserEmailChanges(
+ username: string,
+ cursor?: string | null,
+ limit?: number,
+ ): Promise<
+ ApiResponse<{ changes: UserEmailChange[]; next_cursor: string | null }>
+ > {
+ const endpoint = `${this.getApiSchemeAndHost()}/moderation/user_emails/list/${encodeURIComponent(username)}`;
+ return await this.get<{
+ success: boolean;
+ changes: UserEmailChange[];
+ maybe_cursor: string | null;
+ error_message?: string;
+ }>({
+ endpoint,
+ query: {
+ cursor: cursor ?? undefined,
+ limit,
+ },
+ })
+ .then((response) => ({
+ success: response.success,
+ data: {
+ changes: response.changes || [],
+ next_cursor: response.maybe_cursor ?? null,
+ },
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+}
diff --git a/frontend/apps/artcraft-dashboard/src/api/UsersApi.ts b/frontend/apps/artcraft-dashboard/src/api/UsersApi.ts
new file mode 100644
index 0000000000..62b4fe65af
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/api/UsersApi.ts
@@ -0,0 +1,299 @@
+import { ApiManager, type ApiResponse } from "./ApiManager";
+import type { UserInfo, AppStateResponse } from "@/types";
+
+interface SignupRequest {
+ username: string;
+ email_address: string;
+ password: string;
+ password_confirmation: string;
+ signup_source?: string;
+}
+
+export interface OnboardingData {
+ email_not_set: boolean;
+ email_not_confirmed: boolean;
+ password_not_set: boolean;
+ username_not_customized: boolean;
+}
+
+export class UsersApi extends ApiManager {
+ public GetAppState(): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/app_state`;
+ return this.get({ endpoint })
+ .then((response) => ({
+ success: response.success,
+ data: response,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public GetUserProfile(username: string): Promise<
+ ApiResponse<{
+ user?: UserInfo;
+ }>
+ > {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/user/${username}/profile`;
+ return this.get<{
+ success: boolean;
+ user?: {
+ user_token: string;
+ username: string;
+ display_name: string;
+ email_gravatar_hash: string;
+ disable_gravatar: boolean;
+ created_at: string;
+ [key: string]: any;
+ };
+ error_message?: string;
+ }>({ endpoint })
+ .then((response) => ({
+ success: response.success,
+ data: {
+ user: response.user
+ ? {
+ id: response.user.user_token,
+ username: response.user.username,
+ display_name: response.user.display_name,
+ email: "",
+ gravatar_hash: response.user.email_gravatar_hash,
+ disable_gravatar: response.user.disable_gravatar,
+ created_at: response.user.created_at,
+ }
+ : undefined,
+ },
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async Login({
+ usernameOrEmail,
+ password,
+ }: {
+ usernameOrEmail: string;
+ password: string;
+ }): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/login`;
+ const body = {
+ username_or_email: usernameOrEmail,
+ password,
+ };
+ return await this.post<
+ { username_or_email: string; password: string },
+ {
+ success: boolean;
+ signed_session?: string;
+ error_message?: string;
+ error_type?: string;
+ }
+ >({
+ endpoint,
+ body,
+ })
+ .then((response) => ({
+ success: response.success,
+ data: { signedSession: response.signed_session },
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public Logout(): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/logout`;
+ return this.post({
+ endpoint,
+ })
+ .then((response) => ({
+ success: response.success,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async Signup({
+ username,
+ email,
+ password,
+ passwordConfirmation,
+ signupSource,
+ }: {
+ username: string;
+ email: string;
+ password: string;
+ passwordConfirmation: string;
+ signupSource?: string;
+ }): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/create_account`;
+ const body: SignupRequest = {
+ email_address: email,
+ password,
+ password_confirmation: passwordConfirmation,
+ username,
+ };
+ if (signupSource) {
+ body.signup_source = signupSource;
+ }
+ return await this.post<
+ SignupRequest,
+ {
+ success: boolean;
+ signed_session?: string;
+ error_fields?: Record;
+ error_message?: string;
+ error_type?: string;
+ }
+ >({
+ endpoint,
+ body,
+ })
+ .then((response) => ({
+ success: response.success,
+ data: { signedSession: response.signed_session },
+ errorMessage:
+ response.error_message ||
+ Object.values(response.error_fields ?? {}).join(", "),
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async RequestPasswordReset({
+ email,
+ }: {
+ email: string;
+ }): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/request_password_reset`;
+ return await this.post<
+ { email: string },
+ { success: boolean; error_message?: string }
+ >({
+ endpoint,
+ body: { email },
+ })
+ .then((response) => ({
+ success: response.success,
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async ResetPassword({
+ token,
+ password,
+ passwordConfirmation,
+ }: {
+ token: string;
+ password: string;
+ passwordConfirmation: string;
+ }): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/reset_password`;
+ return await this.post<
+ { token: string; password: string; password_confirmation: string },
+ { success: boolean; error_message?: string }
+ >({
+ endpoint,
+ body: {
+ token,
+ password,
+ password_confirmation: passwordConfirmation,
+ },
+ })
+ .then((response) => ({
+ success: response.success,
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async ChangePassword({
+ password,
+ passwordConfirmation,
+ }: {
+ password: string;
+ passwordConfirmation: string;
+ }): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/user/change_password`;
+ return await this.post<
+ { password: string; password_confirmation: string },
+ { success: boolean; error_message?: string }
+ >({
+ endpoint,
+ body: {
+ password,
+ password_confirmation: passwordConfirmation,
+ },
+ })
+ .then((response) => ({
+ success: response.success,
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async EditEmail({
+ emailAddress,
+ }: {
+ emailAddress: string;
+ }): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/user/edit_email`;
+ return await this.post<
+ { email_address: string },
+ { success: boolean; error_message?: string }
+ >({
+ endpoint,
+ body: { email_address: emailAddress },
+ })
+ .then((response) => ({
+ success: response.success,
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+
+ public async EditUsername({
+ displayName,
+ }: {
+ displayName: string;
+ }): Promise> {
+ const endpoint = `${this.getApiSchemeAndHost()}/v1/user/edit_username`;
+ return await this.post<
+ { display_name: string },
+ { success: boolean; error_message?: string }
+ >({
+ endpoint,
+ body: { display_name: displayName },
+ })
+ .then((response) => ({
+ success: response.success,
+ errorMessage: response.error_message,
+ }))
+ .catch((err) => ({
+ success: false,
+ errorMessage: err.message,
+ }));
+ }
+}
diff --git a/frontend/apps/artcraft-dashboard/src/app/dashboard/data.json b/frontend/apps/artcraft-dashboard/src/app/dashboard/data.json
new file mode 100644
index 0000000000..ec0873641b
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/app/dashboard/data.json
@@ -0,0 +1,614 @@
+[
+ {
+ "id": 1,
+ "header": "Cover page",
+ "type": "Cover page",
+ "status": "In Process",
+ "target": "18",
+ "limit": "5",
+ "reviewer": "Eddie Lake"
+ },
+ {
+ "id": 2,
+ "header": "Table of contents",
+ "type": "Table of contents",
+ "status": "Done",
+ "target": "29",
+ "limit": "24",
+ "reviewer": "Eddie Lake"
+ },
+ {
+ "id": 3,
+ "header": "Executive summary",
+ "type": "Narrative",
+ "status": "Done",
+ "target": "10",
+ "limit": "13",
+ "reviewer": "Eddie Lake"
+ },
+ {
+ "id": 4,
+ "header": "Technical approach",
+ "type": "Narrative",
+ "status": "Done",
+ "target": "27",
+ "limit": "23",
+ "reviewer": "Jamik Tashpulatov"
+ },
+ {
+ "id": 5,
+ "header": "Design",
+ "type": "Narrative",
+ "status": "In Process",
+ "target": "2",
+ "limit": "16",
+ "reviewer": "Jamik Tashpulatov"
+ },
+ {
+ "id": 6,
+ "header": "Capabilities",
+ "type": "Narrative",
+ "status": "In Process",
+ "target": "20",
+ "limit": "8",
+ "reviewer": "Jamik Tashpulatov"
+ },
+ {
+ "id": 7,
+ "header": "Integration with existing systems",
+ "type": "Narrative",
+ "status": "In Process",
+ "target": "19",
+ "limit": "21",
+ "reviewer": "Jamik Tashpulatov"
+ },
+ {
+ "id": 8,
+ "header": "Innovation and Advantages",
+ "type": "Narrative",
+ "status": "Done",
+ "target": "25",
+ "limit": "26",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 9,
+ "header": "Overview of EMR's Innovative Solutions",
+ "type": "Technical content",
+ "status": "Done",
+ "target": "7",
+ "limit": "23",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 10,
+ "header": "Advanced Algorithms and Machine Learning",
+ "type": "Narrative",
+ "status": "Done",
+ "target": "30",
+ "limit": "28",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 11,
+ "header": "Adaptive Communication Protocols",
+ "type": "Narrative",
+ "status": "Done",
+ "target": "9",
+ "limit": "31",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 12,
+ "header": "Advantages Over Current Technologies",
+ "type": "Narrative",
+ "status": "Done",
+ "target": "12",
+ "limit": "0",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 13,
+ "header": "Past Performance",
+ "type": "Narrative",
+ "status": "Done",
+ "target": "22",
+ "limit": "33",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 14,
+ "header": "Customer Feedback and Satisfaction Levels",
+ "type": "Narrative",
+ "status": "Done",
+ "target": "15",
+ "limit": "34",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 15,
+ "header": "Implementation Challenges and Solutions",
+ "type": "Narrative",
+ "status": "Done",
+ "target": "3",
+ "limit": "35",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 16,
+ "header": "Security Measures and Data Protection Policies",
+ "type": "Narrative",
+ "status": "In Process",
+ "target": "6",
+ "limit": "36",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 17,
+ "header": "Scalability and Future Proofing",
+ "type": "Narrative",
+ "status": "Done",
+ "target": "4",
+ "limit": "37",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 18,
+ "header": "Cost-Benefit Analysis",
+ "type": "Plain language",
+ "status": "Done",
+ "target": "14",
+ "limit": "38",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 19,
+ "header": "User Training and Onboarding Experience",
+ "type": "Narrative",
+ "status": "Done",
+ "target": "17",
+ "limit": "39",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 20,
+ "header": "Future Development Roadmap",
+ "type": "Narrative",
+ "status": "Done",
+ "target": "11",
+ "limit": "40",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 21,
+ "header": "System Architecture Overview",
+ "type": "Technical content",
+ "status": "In Process",
+ "target": "24",
+ "limit": "18",
+ "reviewer": "Maya Johnson"
+ },
+ {
+ "id": 22,
+ "header": "Risk Management Plan",
+ "type": "Narrative",
+ "status": "Done",
+ "target": "15",
+ "limit": "22",
+ "reviewer": "Carlos Rodriguez"
+ },
+ {
+ "id": 23,
+ "header": "Compliance Documentation",
+ "type": "Legal",
+ "status": "In Process",
+ "target": "31",
+ "limit": "27",
+ "reviewer": "Sarah Chen"
+ },
+ {
+ "id": 24,
+ "header": "API Documentation",
+ "type": "Technical content",
+ "status": "Done",
+ "target": "8",
+ "limit": "12",
+ "reviewer": "Raj Patel"
+ },
+ {
+ "id": 25,
+ "header": "User Interface Mockups",
+ "type": "Visual",
+ "status": "In Process",
+ "target": "19",
+ "limit": "25",
+ "reviewer": "Leila Ahmadi"
+ },
+ {
+ "id": 26,
+ "header": "Database Schema",
+ "type": "Technical content",
+ "status": "Done",
+ "target": "22",
+ "limit": "20",
+ "reviewer": "Thomas Wilson"
+ },
+ {
+ "id": 27,
+ "header": "Testing Methodology",
+ "type": "Technical content",
+ "status": "In Process",
+ "target": "17",
+ "limit": "14",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 28,
+ "header": "Deployment Strategy",
+ "type": "Narrative",
+ "status": "Done",
+ "target": "26",
+ "limit": "30",
+ "reviewer": "Eddie Lake"
+ },
+ {
+ "id": 29,
+ "header": "Budget Breakdown",
+ "type": "Financial",
+ "status": "In Process",
+ "target": "13",
+ "limit": "16",
+ "reviewer": "Jamik Tashpulatov"
+ },
+ {
+ "id": 30,
+ "header": "Market Analysis",
+ "type": "Research",
+ "status": "Done",
+ "target": "29",
+ "limit": "32",
+ "reviewer": "Sophia Martinez"
+ },
+ {
+ "id": 31,
+ "header": "Competitor Comparison",
+ "type": "Research",
+ "status": "In Process",
+ "target": "21",
+ "limit": "19",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 32,
+ "header": "Maintenance Plan",
+ "type": "Technical content",
+ "status": "Done",
+ "target": "16",
+ "limit": "23",
+ "reviewer": "Alex Thompson"
+ },
+ {
+ "id": 33,
+ "header": "User Personas",
+ "type": "Research",
+ "status": "In Process",
+ "target": "27",
+ "limit": "24",
+ "reviewer": "Nina Patel"
+ },
+ {
+ "id": 34,
+ "header": "Accessibility Compliance",
+ "type": "Legal",
+ "status": "Done",
+ "target": "18",
+ "limit": "21",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 35,
+ "header": "Performance Metrics",
+ "type": "Technical content",
+ "status": "In Process",
+ "target": "23",
+ "limit": "26",
+ "reviewer": "David Kim"
+ },
+ {
+ "id": 36,
+ "header": "Disaster Recovery Plan",
+ "type": "Technical content",
+ "status": "Done",
+ "target": "14",
+ "limit": "17",
+ "reviewer": "Jamik Tashpulatov"
+ },
+ {
+ "id": 37,
+ "header": "Third-party Integrations",
+ "type": "Technical content",
+ "status": "In Process",
+ "target": "25",
+ "limit": "28",
+ "reviewer": "Eddie Lake"
+ },
+ {
+ "id": 38,
+ "header": "User Feedback Summary",
+ "type": "Research",
+ "status": "Done",
+ "target": "20",
+ "limit": "15",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 39,
+ "header": "Localization Strategy",
+ "type": "Narrative",
+ "status": "In Process",
+ "target": "12",
+ "limit": "19",
+ "reviewer": "Maria Garcia"
+ },
+ {
+ "id": 40,
+ "header": "Mobile Compatibility",
+ "type": "Technical content",
+ "status": "Done",
+ "target": "28",
+ "limit": "31",
+ "reviewer": "James Wilson"
+ },
+ {
+ "id": 41,
+ "header": "Data Migration Plan",
+ "type": "Technical content",
+ "status": "In Process",
+ "target": "19",
+ "limit": "22",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 42,
+ "header": "Quality Assurance Protocols",
+ "type": "Technical content",
+ "status": "Done",
+ "target": "30",
+ "limit": "33",
+ "reviewer": "Priya Singh"
+ },
+ {
+ "id": 43,
+ "header": "Stakeholder Analysis",
+ "type": "Research",
+ "status": "In Process",
+ "target": "11",
+ "limit": "14",
+ "reviewer": "Eddie Lake"
+ },
+ {
+ "id": 44,
+ "header": "Environmental Impact Assessment",
+ "type": "Research",
+ "status": "Done",
+ "target": "24",
+ "limit": "27",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 45,
+ "header": "Intellectual Property Rights",
+ "type": "Legal",
+ "status": "In Process",
+ "target": "17",
+ "limit": "20",
+ "reviewer": "Sarah Johnson"
+ },
+ {
+ "id": 46,
+ "header": "Customer Support Framework",
+ "type": "Narrative",
+ "status": "Done",
+ "target": "22",
+ "limit": "25",
+ "reviewer": "Jamik Tashpulatov"
+ },
+ {
+ "id": 47,
+ "header": "Version Control Strategy",
+ "type": "Technical content",
+ "status": "In Process",
+ "target": "15",
+ "limit": "18",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 48,
+ "header": "Continuous Integration Pipeline",
+ "type": "Technical content",
+ "status": "Done",
+ "target": "26",
+ "limit": "29",
+ "reviewer": "Michael Chen"
+ },
+ {
+ "id": 49,
+ "header": "Regulatory Compliance",
+ "type": "Legal",
+ "status": "In Process",
+ "target": "13",
+ "limit": "16",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 50,
+ "header": "User Authentication System",
+ "type": "Technical content",
+ "status": "Done",
+ "target": "28",
+ "limit": "31",
+ "reviewer": "Eddie Lake"
+ },
+ {
+ "id": 51,
+ "header": "Data Analytics Framework",
+ "type": "Technical content",
+ "status": "In Process",
+ "target": "21",
+ "limit": "24",
+ "reviewer": "Jamik Tashpulatov"
+ },
+ {
+ "id": 52,
+ "header": "Cloud Infrastructure",
+ "type": "Technical content",
+ "status": "Done",
+ "target": "16",
+ "limit": "19",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 53,
+ "header": "Network Security Measures",
+ "type": "Technical content",
+ "status": "In Process",
+ "target": "29",
+ "limit": "32",
+ "reviewer": "Lisa Wong"
+ },
+ {
+ "id": 54,
+ "header": "Project Timeline",
+ "type": "Planning",
+ "status": "Done",
+ "target": "14",
+ "limit": "17",
+ "reviewer": "Eddie Lake"
+ },
+ {
+ "id": 55,
+ "header": "Resource Allocation",
+ "type": "Planning",
+ "status": "In Process",
+ "target": "27",
+ "limit": "30",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 56,
+ "header": "Team Structure and Roles",
+ "type": "Planning",
+ "status": "Done",
+ "target": "20",
+ "limit": "23",
+ "reviewer": "Jamik Tashpulatov"
+ },
+ {
+ "id": 57,
+ "header": "Communication Protocols",
+ "type": "Planning",
+ "status": "In Process",
+ "target": "15",
+ "limit": "18",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 58,
+ "header": "Success Metrics",
+ "type": "Planning",
+ "status": "Done",
+ "target": "30",
+ "limit": "33",
+ "reviewer": "Eddie Lake"
+ },
+ {
+ "id": 59,
+ "header": "Internationalization Support",
+ "type": "Technical content",
+ "status": "In Process",
+ "target": "23",
+ "limit": "26",
+ "reviewer": "Jamik Tashpulatov"
+ },
+ {
+ "id": 60,
+ "header": "Backup and Recovery Procedures",
+ "type": "Technical content",
+ "status": "Done",
+ "target": "18",
+ "limit": "21",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 61,
+ "header": "Monitoring and Alerting System",
+ "type": "Technical content",
+ "status": "In Process",
+ "target": "25",
+ "limit": "28",
+ "reviewer": "Daniel Park"
+ },
+ {
+ "id": 62,
+ "header": "Code Review Guidelines",
+ "type": "Technical content",
+ "status": "Done",
+ "target": "12",
+ "limit": "15",
+ "reviewer": "Eddie Lake"
+ },
+ {
+ "id": 63,
+ "header": "Documentation Standards",
+ "type": "Technical content",
+ "status": "In Process",
+ "target": "27",
+ "limit": "30",
+ "reviewer": "Jamik Tashpulatov"
+ },
+ {
+ "id": 64,
+ "header": "Release Management Process",
+ "type": "Planning",
+ "status": "Done",
+ "target": "22",
+ "limit": "25",
+ "reviewer": "Assign reviewer"
+ },
+ {
+ "id": 65,
+ "header": "Feature Prioritization Matrix",
+ "type": "Planning",
+ "status": "In Process",
+ "target": "19",
+ "limit": "22",
+ "reviewer": "Emma Davis"
+ },
+ {
+ "id": 66,
+ "header": "Technical Debt Assessment",
+ "type": "Technical content",
+ "status": "Done",
+ "target": "24",
+ "limit": "27",
+ "reviewer": "Eddie Lake"
+ },
+ {
+ "id": 67,
+ "header": "Capacity Planning",
+ "type": "Planning",
+ "status": "In Process",
+ "target": "21",
+ "limit": "24",
+ "reviewer": "Jamik Tashpulatov"
+ },
+ {
+ "id": 68,
+ "header": "Service Level Agreements",
+ "type": "Legal",
+ "status": "Done",
+ "target": "26",
+ "limit": "29",
+ "reviewer": "Assign reviewer"
+ }
+]
diff --git a/frontend/apps/artcraft-dashboard/src/assets/artcraft-icon.svg b/frontend/apps/artcraft-dashboard/src/assets/artcraft-icon.svg
new file mode 100644
index 0000000000..efc0be9e7f
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/assets/artcraft-icon.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/apps/artcraft-dashboard/src/assets/react.svg b/frontend/apps/artcraft-dashboard/src/assets/react.svg
new file mode 100644
index 0000000000..6c87de9bb3
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/apps/artcraft-dashboard/src/components/BanUserDialog.tsx b/frontend/apps/artcraft-dashboard/src/components/BanUserDialog.tsx
new file mode 100644
index 0000000000..4d6a03c835
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/BanUserDialog.tsx
@@ -0,0 +1,216 @@
+import { useEffect, useState } from "react";
+import { ModerationApi } from "@/api/ModerationApi";
+import { Button } from "@/components/ui/button";
+import { Spinner } from "@/components/ui/spinner";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Label } from "@/components/ui/label";
+import {
+ IconAlertCircle,
+ IconBan,
+ IconShieldCheck,
+} from "@tabler/icons-react";
+import { cn } from "@/lib/utils";
+
+interface BanUserDialogProps {
+ username: string;
+ displayName: string;
+ isBanned: boolean;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSuccess?: () => void;
+}
+
+export function BanUserDialog({
+ username,
+ displayName,
+ isBanned,
+ open,
+ onOpenChange,
+ onSuccess,
+}: BanUserDialogProps) {
+ const [modNotes, setModNotes] = useState("");
+ const [confirmOpen, setConfirmOpen] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!open) {
+ setModNotes("");
+ setConfirmOpen(false);
+ setIsSubmitting(false);
+ setError(null);
+ }
+ }, [open]);
+
+ const trimmedNotes = modNotes.trim();
+ const isValid = trimmedNotes.length > 0;
+ const willBan = !isBanned;
+ const actionLabel = willBan ? "Ban" : "Unban";
+ const ActionIcon = willBan ? IconBan : IconShieldCheck;
+
+ const handleConfirm = async () => {
+ if (!isValid || isSubmitting) return;
+ setIsSubmitting(true);
+ setError(null);
+ const modApi = new ModerationApi();
+ const resp = await modApi.ManageUserBan({
+ username,
+ is_banned: willBan,
+ mod_notes: trimmedNotes,
+ });
+ setIsSubmitting(false);
+ if (resp.success) {
+ setConfirmOpen(false);
+ onOpenChange(false);
+ onSuccess?.();
+ } else {
+ setError(resp.errorMessage || "Failed to update ban status");
+ setConfirmOpen(false);
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
+ {actionLabel} User
+
+
+ {willBan ? (
+ <>
+ You are about to ban{" "}
+
+ {displayName}
+ {" "}
+ (@{username}). They will lose access to their account.
+ >
+ ) : (
+ <>
+ You are about to unban{" "}
+
+ {displayName}
+ {" "}
+ (@{username}). They will regain access to their account.
+ >
+ )}
+
+
+
+
+
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+
+
+ onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ Cancel
+
+ setConfirmOpen(true)}
+ >
+
+ {actionLabel} User
+
+
+
+
+
+
+
+
+ Confirm {actionLabel}
+
+ You are about to{" "}
+
+ {actionLabel.toLowerCase()}
+ {" "}
+
+ {displayName}
+ {" "}
+ (@{username}). This action is recorded in the staff audit log.
+
+
+
+
+ Cancel
+
+ {
+ e.preventDefault();
+ handleConfirm();
+ }}
+ >
+ {isSubmitting ? (
+
+ ) : (
+
+ )}
+ {isSubmitting ? "Saving..." : `Confirm ${actionLabel}`}
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/ChangeUserEmailDialog.tsx b/frontend/apps/artcraft-dashboard/src/components/ChangeUserEmailDialog.tsx
new file mode 100644
index 0000000000..1239a5825b
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/ChangeUserEmailDialog.tsx
@@ -0,0 +1,207 @@
+import { useState } from "react";
+import { ModerationApi } from "@/api/ModerationApi";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Spinner } from "@/components/ui/spinner";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { IconAlertCircle, IconMail } from "@tabler/icons-react";
+
+interface ChangeUserEmailDialogProps {
+ userToken: string;
+ username: string;
+ displayName: string;
+ currentEmail?: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSuccess?: (newEmail: string) => void;
+}
+
+// Mirrors the backend's lenient format check; the server is authoritative.
+const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+export function ChangeUserEmailDialog({
+ userToken,
+ username,
+ displayName,
+ currentEmail,
+ open,
+ onOpenChange,
+ onSuccess,
+}: ChangeUserEmailDialogProps) {
+ const [newEmail, setNewEmail] = useState("");
+ const [confirmOpen, setConfirmOpen] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Reset on close — covers Cancel, escape, outside-click and success paths,
+ // all of which flow through onOpenChange(false).
+ const handleOpenChange = (next: boolean) => {
+ if (!next) {
+ setNewEmail("");
+ setConfirmOpen(false);
+ setIsSubmitting(false);
+ setError(null);
+ }
+ onOpenChange(next);
+ };
+
+ const trimmedEmail = newEmail.trim().toLowerCase();
+ const isValidFormat = EMAIL_REGEX.test(trimmedEmail);
+ const isUnchanged =
+ !!currentEmail && trimmedEmail === currentEmail.trim().toLowerCase();
+ const canSubmit = isValidFormat && !isUnchanged;
+
+ const handleConfirm = async () => {
+ if (!canSubmit || isSubmitting) return;
+ setIsSubmitting(true);
+ setError(null);
+ const modApi = new ModerationApi();
+ const resp = await modApi.ChangeUserEmail(userToken, trimmedEmail);
+ setIsSubmitting(false);
+ if (resp.success) {
+ handleOpenChange(false);
+ onSuccess?.(trimmedEmail);
+ } else {
+ setError(resp.errorMessage || "Failed to change email address");
+ setConfirmOpen(false);
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
+ Change Email Address
+
+
+ Set a new email address for{" "}
+
+ {displayName}
+ {" "}
+ (@{username}).
+
+
+
+
+ {currentEmail && (
+
+
+ Current email
+
+
+ {currentEmail}
+
+
+ )}
+
+
+
+ New email address
+
+
setNewEmail(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && canSubmit) setConfirmOpen(true);
+ }}
+ />
+
+ Recorded in the email-change history and the staff audit log.
+
+
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+
+
+ handleOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ Cancel
+
+ setConfirmOpen(true)}
+ >
+
+ Change Email
+
+
+
+
+
+
+
+
+ Confirm email change
+
+ You are about to change the email address for{" "}
+
+ {displayName}
+ {" "}
+ (@{username}) to{" "}
+
+ {trimmedEmail}
+
+ . This action is recorded in the staff audit log.
+
+
+
+
+ Cancel
+
+ {
+ e.preventDefault();
+ handleConfirm();
+ }}
+ >
+ {isSubmitting ? (
+
+ ) : (
+
+ )}
+ {isSubmitting ? "Saving..." : "Confirm Change"}
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/GiveCreditsDialog.tsx b/frontend/apps/artcraft-dashboard/src/components/GiveCreditsDialog.tsx
new file mode 100644
index 0000000000..f6802a880d
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/GiveCreditsDialog.tsx
@@ -0,0 +1,174 @@
+import { useState } from "react";
+import { ModerationApi } from "@/api/ModerationApi";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Spinner } from "@/components/ui/spinner";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { IconCoin } from "@tabler/icons-react";
+
+interface GiveCreditsDialogProps {
+ walletToken: string;
+ displayName: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSuccess: () => void;
+}
+
+export function GiveCreditsDialog({
+ walletToken,
+ displayName,
+ open,
+ onOpenChange,
+ onSuccess,
+}: GiveCreditsDialogProps) {
+ const [confirmOpen, setConfirmOpen] = useState(false);
+ const [amount, setAmount] = useState("");
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const parsedAmount = parseInt(amount, 10);
+ const isValid = !isNaN(parsedAmount) && parsedAmount > 0;
+ const dollarValue = isValid ? (parsedAmount / 100).toFixed(2) : null;
+
+ const reset = () => {
+ setAmount("");
+ setConfirmOpen(false);
+ setIsSubmitting(false);
+ };
+
+ const handleConfirm = async () => {
+ if (!isValid) return;
+ setIsSubmitting(true);
+ const modApi = new ModerationApi();
+ const resp = await modApi.AddBankedBalance(walletToken, parsedAmount);
+ if (resp.success) {
+ onSuccess();
+ }
+ reset();
+ onOpenChange(false);
+ };
+
+ return (
+ <>
+ {
+ onOpenChange(o);
+ if (!o) reset();
+ }}
+ >
+
+
+ Give Banked Credits
+
+ Add purchased (banked) credits to{" "}
+
+ {displayName}
+
+ 's wallet.
+
+
+
+
setAmount(e.target.value)}
+ />
+
+ 100 credits = $1.00 (1 credit = 1¢)
+ {dollarValue && (
+
+ · {parsedAmount.toLocaleString()} credits = $
+ {dollarValue} USD
+
+ )}
+
+
+
+ onOpenChange(false)}
+ >
+ Cancel
+
+ setConfirmOpen(true)}
+ >
+
+ Continue
+
+
+
+
+
+
+
+
+ Confirm Credit Addition
+
+ You are about to add{" "}
+
+ {isValid ? parsedAmount.toLocaleString() : 0}
+ {" "}
+ banked credits{" "}
+ {dollarValue && (
+ <>
+ (
+
+ ${dollarValue}
+
+ ){" "}
+ >
+ )}
+ to{" "}
+
+ {displayName}
+
+ . This action cannot be undone.
+
+
+
+
+ Cancel
+
+ {
+ e.preventDefault();
+ handleConfirm();
+ }}
+ >
+ {isSubmitting ? (
+
+ ) : (
+
+ )}
+ {isSubmitting ? "Adding..." : "Confirm"}
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/ImpersonateUserDialog.tsx b/frontend/apps/artcraft-dashboard/src/components/ImpersonateUserDialog.tsx
new file mode 100644
index 0000000000..d746f3b7f7
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/ImpersonateUserDialog.tsx
@@ -0,0 +1,163 @@
+import { useEffect, useState } from "react";
+import { ModerationApi } from "@/api/ModerationApi";
+import { Button } from "@/components/ui/button";
+import { Spinner } from "@/components/ui/spinner";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import {
+ IconAlertCircle,
+ IconCheck,
+ IconCopy,
+ IconKey,
+} from "@tabler/icons-react";
+
+interface ImpersonateUserDialogProps {
+ userToken: string;
+ username: string;
+ displayName: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function ImpersonateUserDialog({
+ userToken,
+ username,
+ displayName,
+ open,
+ onOpenChange,
+}: ImpersonateUserDialogProps) {
+ const [isLoading, setIsLoading] = useState(true);
+ const [impersonationToken, setImpersonationToken] = useState(
+ null,
+ );
+ const [error, setError] = useState(null);
+ const [copiedField, setCopiedField] = useState(null);
+
+ useEffect(() => {
+ if (!open) {
+ setImpersonationToken(null);
+ setError(null);
+ setCopiedField(null);
+ setIsLoading(true);
+ return;
+ }
+
+ let cancelled = false;
+ setIsLoading(true);
+ setError(null);
+ setImpersonationToken(null);
+
+ const modApi = new ModerationApi();
+ modApi.ImpersonateUser({ user_token: userToken }).then((resp) => {
+ if (cancelled) return;
+ if (resp.success && resp.data) {
+ setImpersonationToken(resp.data.password_token);
+ } else {
+ setError(resp.errorMessage || "Failed to generate impersonation token");
+ }
+ setIsLoading(false);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [open, userToken]);
+
+ const copy = (text: string, field: string) => {
+ navigator.clipboard.writeText(text).then(() => {
+ setCopiedField(field);
+ setTimeout(() => setCopiedField(null), 2000);
+ });
+ };
+
+ return (
+
+
+
+
+
+ Impersonate User
+
+
+ Use the credentials below to log in as{" "}
+
+ {displayName}
+
+ .
+
+
+
+ {isLoading && (
+
+
+
+ )}
+
+ {error && !isLoading && (
+
+
+ {error}
+
+ )}
+
+ {impersonationToken && !isLoading && (
+
+
+
+ Username
+
+ copy(username, "username")}
+ >
+ {copiedField === "username" ? (
+
+ ) : (
+
+ )}
+ {username}
+
+
+
+
+ Password (Impersonation Token)
+
+ copy(impersonationToken, "token")}
+ >
+ {copiedField === "token" ? (
+
+ ) : (
+
+ )}
+ {impersonationToken}
+
+
+
+ )}
+
+
+ onOpenChange(false)}
+ >
+ Close
+
+
+
+
+ );
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/JobsTable.tsx b/frontend/apps/artcraft-dashboard/src/components/JobsTable.tsx
new file mode 100644
index 0000000000..1cf406d4eb
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/JobsTable.tsx
@@ -0,0 +1,298 @@
+import { useState } from "react";
+import { Link } from "react-router-dom";
+import type { UserJob } from "@/types";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ Table,
+ TableHeader,
+ TableBody,
+ TableRow,
+ TableHead,
+ TableCell,
+} from "@/components/ui/table";
+import { IconBriefcase, IconExternalLink, IconCopy, IconCheck } from "@tabler/icons-react";
+
+export function getJobStatusBadgeProps(jobStatus: string): {
+ variant: "default" | "secondary" | "destructive" | "outline";
+ className: string;
+ label: string;
+ isFailed: boolean;
+ isComplete: boolean;
+} {
+ const status = jobStatus.toLowerCase();
+ const isFailed =
+ status.includes("dead") ||
+ status.includes("fail") ||
+ status.includes("error");
+ const isComplete = status.includes("complete") || status.includes("success");
+
+ if (isFailed) {
+ return {
+ variant: "secondary",
+ className: "bg-destructive/10 text-destructive border-transparent",
+ label: "Failed",
+ isFailed,
+ isComplete,
+ };
+ }
+ if (isComplete) {
+ return {
+ variant: "secondary",
+ className: "bg-emerald-500/10 text-emerald-400 border-transparent",
+ label: "Completed",
+ isFailed,
+ isComplete,
+ };
+ }
+ return {
+ variant: "outline",
+ className: "text-muted-foreground",
+ label: "Pending",
+ isFailed,
+ isComplete,
+ };
+}
+
+interface JobsTableProps {
+ jobs: UserJob[];
+ isLoading?: boolean;
+ skeletonRows?: number;
+ maxHeight?: number;
+}
+
+export function JobsTable({
+ jobs,
+ isLoading = false,
+ skeletonRows = 5,
+ maxHeight,
+}: JobsTableProps) {
+ const [copiedToken, setCopiedToken] = useState(null);
+
+ const copyToClipboard = (text: string) => {
+ navigator.clipboard.writeText(text).then(() => {
+ setCopiedToken(text);
+ setTimeout(() => setCopiedToken(null), 2000);
+ });
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ Token
+ Status
+ Credits
+ Failure Reason
+ Refund
+ Result
+ 3rd Party
+ 3rd Party ID
+ Created
+ Updated
+
+
+
+ {Array.from({ length: skeletonRows }).map((_, i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+ }
+
+ if (jobs.length === 0) {
+ return (
+
+
+
No jobs found for this user.
+
+ );
+ }
+
+ return (
+
+
+
+ Token
+ Status
+ Credits
+ Failure Reason
+ Refund
+ Result
+ 3rd Party
+ 3rd Party ID
+ Created
+ Updated
+
+
+
+ {jobs.map((job) => {
+ const badge = getJobStatusBadgeProps(job.job_status);
+
+ return (
+
+
+
+
+ {job.job_token}
+
+ copyToClipboard(job.job_token)}
+ title="Copy job token"
+ >
+ {copiedToken === job.job_token ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {badge.label}
+
+
+
+ {job.credits_delta != null ? (
+ 0
+ ? "text-emerald-400"
+ : job.credits_delta < 0
+ ? "text-destructive"
+ : "text-muted-foreground"
+ }
+ >
+ {job.credits_delta > 0 ? "+" : ""}
+ {job.credits_delta.toLocaleString()}
+
+ ) : (
+ —
+ )}
+
+
+ {job.job_failure_reason ? (
+
+ {job.job_failure_reason}
+
+ ) : (
+ —
+ )}
+
+
+ {job.maybe_linked_refund_ledger_token ? (
+
+ Refunded
+
+ ) : badge.isFailed &&
+ job.credits_delta != null &&
+ job.credits_delta < 0 ? (
+
+ Needs refund
+
+ ) : (
+ —
+ )}
+
+
+ {job.on_success_result_media_token ? (
+
+
+ View
+
+ ) : (
+ —
+ )}
+
+
+ {job.maybe_external_third_party ? (
+ {job.maybe_external_third_party}
+ ) : (
+ —
+ )}
+
+
+ {job.maybe_external_third_party_id ? (
+ copyToClipboard(job.maybe_external_third_party_id!)}
+ title="Copy 3rd party ID"
+ >
+ {copiedToken === job.maybe_external_third_party_id ? (
+
+ ) : (
+
+ )}
+ {job.maybe_external_third_party_id}
+
+ ) : (
+ —
+ )}
+
+
+ {new Date(job.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}{" "}
+ {new Date(job.created_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}
+
+
+ {new Date(job.updated_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}{" "}
+ {new Date(job.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/MediaCard.tsx b/frontend/apps/artcraft-dashboard/src/components/MediaCard.tsx
new file mode 100644
index 0000000000..60f0353571
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/MediaCard.tsx
@@ -0,0 +1,214 @@
+import { useState, useCallback } from "react";
+import { Link } from "react-router-dom";
+import {
+ IconPhoto,
+ IconVideo,
+ IconCube,
+ IconCloudUpload,
+} from "@tabler/icons-react";
+import { Spinner } from "@/components/ui/spinner";
+import type { MediaInfo, ExploreMediaFile } from "@/api/MediaApi";
+import { getMediaThumbnailUrl } from "@/api/MediaApi";
+import { MediaDetailModal } from "@/components/MediaDetailModal";
+
+export type MediaCardCreator = {
+ username: string;
+ display_name?: string;
+ gravatar_hash?: string;
+};
+
+interface MediaCardProps {
+ item: MediaInfo | ExploreMediaFile;
+ showCreator?: boolean;
+ /** Override or fallback when the item doesn't embed a creator (e.g. on a user profile page). */
+ creator?: MediaCardCreator | null;
+ onSelect?: (item: MediaInfo | ExploreMediaFile) => void;
+}
+
+const classIcons: Record = {
+ video: IconVideo,
+ dimensional: IconCube,
+};
+
+function formatDuration(ms: number): string {
+ const totalSec = Math.floor(ms / 1000);
+ const min = Math.floor(totalSec / 60);
+ const sec = totalSec % 60;
+ return `${min}:${sec.toString().padStart(2, "0")}`;
+}
+
+function formatCreatedAt(iso: string): { short: string; full: string } | null {
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return null;
+ const short =
+ d.toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ }) +
+ " " +
+ d.toLocaleTimeString(undefined, {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: false,
+ });
+ const full = d.toLocaleString();
+ return { short, full };
+}
+
+export function MediaCard({
+ item,
+ showCreator,
+ creator: creatorOverride,
+ onSelect,
+}: MediaCardProps) {
+ const thumbnailUrl = getMediaThumbnailUrl(item, 512);
+ const mediaClass = item.media_class;
+ const FallbackIcon = classIcons[mediaClass] ?? IconPhoto;
+ const embeddedCreator =
+ "maybe_creator" in item ? item.maybe_creator : undefined;
+ const creator: MediaCardCreator | null | undefined =
+ creatorOverride ?? embeddedCreator;
+ const durationMs =
+ "maybe_duration_millis" in item ? item.maybe_duration_millis : undefined;
+ const createdAt = item.created_at ? formatCreatedAt(item.created_at) : null;
+ const [imgLoaded, setImgLoaded] = useState(!thumbnailUrl);
+ const [retryCount, setRetryCount] = useState(0);
+ const [modalOpen, setModalOpen] = useState(false);
+ const imgSrc = thumbnailUrl
+ ? thumbnailUrl +
+ (retryCount
+ ? `${thumbnailUrl.includes("?") ? "&" : "?"}_r=${retryCount}`
+ : "")
+ : undefined;
+
+ const handleError = () => {
+ setTimeout(() => setRetryCount((c) => c + 1), 5000);
+ };
+
+ const handleClick = useCallback(() => {
+ if (onSelect) {
+ onSelect(item);
+ } else {
+ setModalOpen(true);
+ }
+ }, [onSelect, item]);
+
+ const isUserUpload = Boolean(
+ (item as { is_user_upload?: boolean }).is_user_upload,
+ );
+ const hoverLabel = !isUserUpload
+ ? item.maybe_title || item.maybe_text_transcript
+ : undefined;
+ const hasMetaFooter = showCreator && creator;
+
+ return (
+ <>
+ {
+ if (e.key === "Enter" || e.key === " ") handleClick();
+ }}
+ draggable={false}
+ className="group relative rounded-xl overflow-hidden border bg-muted/30 cursor-pointer aspect-square"
+ >
+ {/* Top-left: media class + upload indicator */}
+
+
+ {mediaClass === "dimensional" ? "3D" : mediaClass || "Unknown"}
+
+ {isUserUpload && (
+
+
+ Uploaded
+
+ )}
+
+
+ {/* Top-right: created date/time + (optional) duration */}
+
+ {createdAt && (
+
+ {createdAt.short}
+
+ )}
+ {durationMs != null && durationMs > 0 && (
+
+ {formatDuration(durationMs)}
+
+ )}
+
+
+ {/* Thumbnail or fallback */}
+ {imgSrc ? (
+ <>
+ {!imgLoaded && (
+
+
+
+ )}
+
setImgLoaded(true)}
+ onError={handleError}
+ />
+ >
+ ) : (
+
+
+
+ )}
+
+ {/* Hover overlay — title / transcript (sits above the always-visible meta strip) */}
+ {hoverLabel && (
+
+ )}
+
+ {/* Always-visible metadata overlay (bottom): creator + token */}
+
+ {hasMetaFooter && (
+ e.stopPropagation()}
+ className="text-sm font-medium text-white truncate hover:underline"
+ title={creator!.display_name || creator!.username}
+ >
+ @{creator!.username}
+
+ )}
+
+ {item.token}
+
+
+
+
+ {!onSelect && (
+
+ )}
+ >
+ );
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/MediaDetailModal.tsx b/frontend/apps/artcraft-dashboard/src/components/MediaDetailModal.tsx
new file mode 100644
index 0000000000..d871f6f71b
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/MediaDetailModal.tsx
@@ -0,0 +1,457 @@
+import { useState, useEffect, useCallback, useLayoutEffect, useRef } from "react";
+import { useNavigate } from "react-router-dom";
+import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
+import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import { Spinner } from "@/components/ui/spinner";
+import {
+ IconDownload,
+ IconPhoto,
+ IconVideo,
+ IconCube,
+ IconExternalLink,
+ IconPencil,
+ IconInfoCircle,
+ IconSettings,
+ IconChevronDown,
+ IconCopy,
+ IconCheck,
+} from "@tabler/icons-react";
+import { getIdenticonUrl } from "@/lib/identicon";
+import type { MediaInfo, ExploreMediaFile, PromptInfo } from "@/api/MediaApi";
+import { MediaApi, getMediaThumbnailUrl } from "@/api/MediaApi";
+
+type MediaItem = MediaInfo | ExploreMediaFile;
+
+export type MediaModalCreator = {
+ username: string;
+ display_name?: string;
+ gravatar_hash?: string;
+};
+
+interface MediaDetailModalProps {
+ item: MediaItem | null;
+ /** Fallback/override creator when the item itself doesn't embed one (e.g. from ListUserMediaFiles). */
+ creator?: MediaModalCreator;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+const classLabels: Record = {
+ image: "Image",
+ video: "Video",
+ dimensional: "3D",
+};
+
+const classIcons: Record = {
+ video: IconVideo,
+ dimensional: IconCube,
+};
+
+function formatModelType(model: string): string {
+ return model
+ .replace(/_/g, " ")
+ .replace(/(\d)p(\d)/g, "$1.$2")
+ .replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+const aspectRatioLabels: Record = {
+ auto: "Auto",
+ square: "1:1",
+ wide_three_by_two: "3:2",
+ wide_four_by_three: "4:3",
+ wide_five_by_four: "5:4",
+ wide_sixteen_by_nine: "16:9",
+ wide_twenty_one_by_nine: "21:9",
+ tall_two_by_three: "2:3",
+ tall_three_by_four: "3:4",
+ tall_four_by_five: "4:5",
+ tall_nine_by_sixteen: "9:16",
+ tall_nine_by_twenty_one: "9:21",
+ wide: "Wide",
+ tall: "Tall",
+ auto_2k: "Auto 2K",
+ auto_4k: "Auto 4K",
+ square_hd: "Square HD",
+};
+
+function getFullMediaUrl(item: MediaItem): string | undefined {
+ if ("media_links" in item && item.media_links?.cdn_url) {
+ return item.media_links.cdn_url;
+ }
+ if ("public_bucket_url" in item && item.public_bucket_url) {
+ return item.public_bucket_url as string;
+ }
+ return undefined;
+}
+
+function getEmbeddedCreator(item: MediaItem) {
+ return "maybe_creator" in item
+ ? (item as ExploreMediaFile).maybe_creator
+ : undefined;
+}
+
+export function MediaDetailModal({
+ item,
+ creator: creatorOverride,
+ open,
+ onOpenChange,
+}: MediaDetailModalProps) {
+ const navigate = useNavigate();
+ const [imgLoaded, setImgLoaded] = useState(false);
+ const [promptInfo, setPromptInfo] = useState(null);
+ const [promptFailed, setPromptFailed] = useState(false);
+ const [promptExpanded, setPromptExpanded] = useState(false);
+ const [isPromptOverflowing, setIsPromptOverflowing] = useState(false);
+ const promptRef = useRef(null);
+ const [additionalOpen, setAdditionalOpen] = useState(false);
+ const [copied, setCopied] = useState(false);
+
+ const handleOpenChange = useCallback(
+ (next: boolean) => {
+ if (!next) {
+ setImgLoaded(false);
+ setPromptInfo(null);
+ setPromptFailed(false);
+ setPromptExpanded(false);
+ setAdditionalOpen(false);
+ setCopied(false);
+ }
+ onOpenChange(next);
+ },
+ [onOpenChange],
+ );
+
+ useEffect(() => {
+ if (!open || !item) return;
+ let cancelled = false;
+ const api = new MediaApi();
+ api.GetMediaFile(item.token).then((res) => {
+ if (cancelled) return;
+ const promptToken = res.data?.maybe_prompt_token;
+ if (!promptToken) {
+ setPromptFailed(true);
+ return;
+ }
+ api.GetPrompt(promptToken).then((promptRes) => {
+ if (cancelled) return;
+ if (promptRes.success && promptRes.data) {
+ setPromptInfo(promptRes.data);
+ } else {
+ setPromptFailed(true);
+ }
+ });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [open, item?.token]);
+
+ const promptText =
+ promptInfo?.maybe_positive_prompt ||
+ item?.maybe_text_transcript ||
+ item?.maybe_title;
+
+ useLayoutEffect(() => {
+ if (promptExpanded) return;
+ const el = promptRef.current;
+ if (!el || !promptText) {
+ setIsPromptOverflowing(false);
+ return;
+ }
+ setIsPromptOverflowing(el.scrollHeight > el.clientHeight + 1);
+ }, [promptText, promptExpanded, open]);
+
+ if (!item) return null;
+
+ const thumbnailUrl = getMediaThumbnailUrl(item, 1024);
+ const fullUrl = getFullMediaUrl(item);
+ const creator = getEmbeddedCreator(item) ?? creatorOverride;
+ const FallbackIcon = classIcons[item.media_class] ?? IconPhoto;
+ const title = item.maybe_title || item.maybe_text_transcript || "Untitled";
+ const isVideo = item.media_class === "video";
+ const videoPoster = isVideo
+ ? item.media_links?.maybe_video_previews?.thumbnail_template?.replace(
+ "{WIDTH}",
+ "1024",
+ ) ||
+ item.media_links?.maybe_thumbnail_template?.replace("{WIDTH}", "1024") ||
+ undefined
+ : undefined;
+ const externalUrl = `https://getartcraft.com/media/${item.token}`;
+
+ const handleDownload = () => {
+ const url = fullUrl || thumbnailUrl;
+ if (url) window.open(url, "_blank");
+ };
+
+ const handleCreatorClick = () => {
+ if (!creator) return;
+ handleOpenChange(false);
+ navigate(`/user/profile/${creator.username}`);
+ };
+
+ const modelDisplay = promptInfo?.maybe_model_type
+ ? formatModelType(promptInfo.maybe_model_type)
+ : null;
+
+ const handleCopyPrompt = async () => {
+ if (!promptText) return;
+ try {
+ await navigator.clipboard.writeText(promptText);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch { /* ignore */ }
+ };
+
+ const generationMode = promptInfo?.maybe_generation_mode
+ ? formatModelType(promptInfo.maybe_generation_mode)
+ : null;
+
+ const resolution = promptInfo?.maybe_resolution
+ ? formatModelType(promptInfo.maybe_resolution)
+ : null;
+
+ const aspectRatio = promptInfo?.maybe_aspect_ratio
+ ? aspectRatioLabels[promptInfo.maybe_aspect_ratio] || formatModelType(promptInfo.maybe_aspect_ratio)
+ : null;
+
+ const createdDate = item.created_at
+ ? new Date(item.created_at).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ })
+ : null;
+
+ return (
+
+
+ {title}
+
+
+ {/* Left: Media preview */}
+
+ {isVideo && fullUrl ? (
+
+ ) : thumbnailUrl ? (
+ <>
+ {!imgLoaded && (
+
+
+
+ )}
+
setImgLoaded(true)}
+ />
+ >
+ ) : (
+
+ )}
+
+
+ {/* Right: Details panel */}
+
+ {/* Creator header - sticky top */}
+ {creator && (
+
+
+
+
+
+
+
+
+
+ {creator.display_name || creator.username}
+
+
Author
+
+
+ )}
+
+ {/* Scrollable details */}
+
+ {/* Prompt section */}
+
+
+
+
+ Prompt
+
+ {promptText && (
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
+ {promptText ||
+ (promptFailed ? "No prompt available" : "Loading...")}
+
+ {promptText && (isPromptOverflowing || promptExpanded) && (
+
setPromptExpanded((v) => !v)}
+ className="flex items-center justify-between w-full text-xs text-muted-foreground hover:text-foreground mt-0 px-2.5 py-1.5 bg-muted/50 rounded-b-lg border-t border-border transition-colors cursor-pointer"
+ >
+ {promptExpanded ? "Collapse" : "See all"}
+
+
+ )}
+
+
+
+ {/* Information section */}
+
+
+
+ Information
+
+
+
+ Feature
+
+ {generationMode ||
+ (classLabels[item.media_class]
+ ? `Create ${classLabels[item.media_class]}`
+ : item.media_class)}
+
+
+
+ Model
+
+ {modelDisplay ?? (
+
+ {promptFailed ? "Unknown" : "Loading..."}
+
+ )}
+
+
+
+ Type
+ {item.media_type}
+
+
+
+
+ {/* Additional section */}
+
+
setAdditionalOpen((v) => !v)}
+ className="flex items-center justify-between w-full"
+ >
+
+
+ Additional
+
+
+
+ {additionalOpen && (
+
+ {resolution && (
+
+ Resolution
+ {resolution}
+
+ )}
+ {aspectRatio && (
+
+ Aspect Ratio
+ {aspectRatio}
+
+ )}
+ {promptInfo?.maybe_generation_provider && (
+
+ Provider
+
+ {formatModelType(promptInfo.maybe_generation_provider)}
+
+
+ )}
+ {createdDate && (
+
+ Created
+ {createdDate}
+
+ )}
+ {!resolution && !aspectRatio && !promptInfo?.maybe_generation_provider && !createdDate && (
+
No additional details
+ )}
+
+ )}
+
+
+
+ {/* Actions - sticky bottom */}
+
+
+
+
+
+ );
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/RawTag.tsx b/frontend/apps/artcraft-dashboard/src/components/RawTag.tsx
new file mode 100644
index 0000000000..40d5bcc9d5
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/RawTag.tsx
@@ -0,0 +1,22 @@
+import { cn } from "@/lib/utils";
+
+interface RawTagProps {
+ value: string;
+ className?: string;
+}
+
+// Renders a raw backend enum value (e.g. "complete_success") in a small muted
+// monospace pill. Pair next to a humanized label so debugging is unambiguous.
+export function RawTag({ value, className }: RawTagProps) {
+ return (
+
+ {value}
+
+ );
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/TokenSearchForm.tsx b/frontend/apps/artcraft-dashboard/src/components/TokenSearchForm.tsx
new file mode 100644
index 0000000000..904eddedcb
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/TokenSearchForm.tsx
@@ -0,0 +1,78 @@
+import { useState, type ComponentType, type FormEvent } from "react";
+import { useNavigate } from "react-router-dom";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Card, CardHeader } from "@/components/ui/card";
+import { IconArrowRight, IconSearch } from "@tabler/icons-react";
+
+interface TokenSearchFormProps {
+ title: string;
+ description: string;
+ inputLabel: string;
+ placeholder: string;
+ buttonLabel: string;
+ /** Builds the destination route given a trimmed token. */
+ buildHref: (token: string) => string;
+ icon: ComponentType<{ className?: string }>;
+}
+
+export function TokenSearchForm({
+ title,
+ description,
+ inputLabel,
+ placeholder,
+ buttonLabel,
+ buildHref,
+ icon: Icon,
+}: TokenSearchFormProps) {
+ const navigate = useNavigate();
+ const [token, setToken] = useState("");
+
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ const trimmed = token.trim();
+ if (!trimmed) return;
+ navigate(buildHref(trimmed));
+ };
+
+ return (
+
+
+
+
+ {title}
+
+
{description}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/UserFeatureFlagsDialog.tsx b/frontend/apps/artcraft-dashboard/src/components/UserFeatureFlagsDialog.tsx
new file mode 100644
index 0000000000..fc0fe7fdc5
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/UserFeatureFlagsDialog.tsx
@@ -0,0 +1,378 @@
+import { useEffect, useMemo, useState } from "react";
+import {
+ ModerationApi,
+ type FeatureFlagDescriptor,
+} from "@/api/ModerationApi";
+import { getFeatureFlagIcon } from "@/lib/feature-flag-icon";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Spinner } from "@/components/ui/spinner";
+import { Badge } from "@/components/ui/badge";
+import { Input } from "@/components/ui/input";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import {
+ IconAlertCircle,
+ IconFlag,
+ IconSearch,
+ IconTrash,
+ IconDeviceFloppy,
+} from "@tabler/icons-react";
+
+interface UserFeatureFlagsDialogProps {
+ usernameOrToken: string;
+ displayName: string;
+ username: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSuccess?: () => void;
+}
+
+export function UserFeatureFlagsDialog({
+ usernameOrToken,
+ displayName,
+ username,
+ open,
+ onOpenChange,
+ onSuccess,
+}: UserFeatureFlagsDialogProps) {
+ const [availableFlags, setAvailableFlags] = useState(
+ [],
+ );
+ const [initialUserFlags, setInitialUserFlags] = useState([]);
+ const [selectedFlags, setSelectedFlags] = useState>(new Set());
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+ const [filter, setFilter] = useState("");
+ const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
+
+ useEffect(() => {
+ if (!open) {
+ setFilter("");
+ setError(null);
+ setClearConfirmOpen(false);
+ return;
+ }
+
+ let cancelled = false;
+ setIsLoading(true);
+ setError(null);
+ setAvailableFlags([]);
+ setInitialUserFlags([]);
+ setSelectedFlags(new Set());
+
+ const api = new ModerationApi();
+ Promise.all([
+ api.ListAvailableFeatureFlags(),
+ api.ListUserFeatureFlags(usernameOrToken),
+ ])
+ .then(([allResp, userResp]) => {
+ if (cancelled) return;
+ if (!allResp.success) {
+ setError(allResp.errorMessage || "Failed to load available flags");
+ return;
+ }
+ if (!userResp.success) {
+ setError(userResp.errorMessage || "Failed to load user flags");
+ return;
+ }
+ const all = allResp.data?.feature_flags ?? [];
+ const user = userResp.data?.feature_flags ?? [];
+ setAvailableFlags(all);
+ setInitialUserFlags(user);
+ setSelectedFlags(new Set(user));
+ })
+ .finally(() => {
+ if (!cancelled) setIsLoading(false);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [open, usernameOrToken]);
+
+ const availableByKey = useMemo(() => {
+ const map = new Map();
+ for (const f of availableFlags) map.set(f.key, f);
+ return map;
+ }, [availableFlags]);
+
+ const allFlagRows = useMemo(() => {
+ const rows: FeatureFlagDescriptor[] = availableFlags.map((f) => ({ ...f }));
+ const seen = new Set(availableFlags.map((f) => f.key));
+ for (const key of initialUserFlags) {
+ if (!seen.has(key)) {
+ rows.push({ key, full_name: key, description: "" });
+ seen.add(key);
+ }
+ }
+ return rows.sort((a, b) => a.key.localeCompare(b.key));
+ }, [availableFlags, initialUserFlags]);
+
+ const filteredFlags = useMemo(() => {
+ const q = filter.trim().toLowerCase();
+ if (!q) return allFlagRows;
+ return allFlagRows.filter(
+ (f) =>
+ f.key.toLowerCase().includes(q) ||
+ f.full_name.toLowerCase().includes(q) ||
+ f.description.toLowerCase().includes(q),
+ );
+ }, [allFlagRows, filter]);
+
+ const hasChanges = useMemo(() => {
+ if (selectedFlags.size !== initialUserFlags.length) return true;
+ for (const f of initialUserFlags) {
+ if (!selectedFlags.has(f)) return true;
+ }
+ return false;
+ }, [selectedFlags, initialUserFlags]);
+
+ const toggleFlag = (flag: string, checked: boolean) => {
+ setSelectedFlags((prev) => {
+ const next = new Set(prev);
+ if (checked) next.add(flag);
+ else next.delete(flag);
+ return next;
+ });
+ };
+
+ const handleSave = async () => {
+ if (isSubmitting || !hasChanges) return;
+ setIsSubmitting(true);
+ setError(null);
+ const api = new ModerationApi();
+ const resp = await api.EditUserFeatureFlags(usernameOrToken, {
+ SetExactFlags: { flags: Array.from(selectedFlags).sort() },
+ });
+ setIsSubmitting(false);
+ if (resp.success) {
+ onSuccess?.();
+ onOpenChange(false);
+ } else {
+ setError(resp.errorMessage || "Failed to update feature flags");
+ }
+ };
+
+ const handleClearAll = async () => {
+ if (isSubmitting) return;
+ setIsSubmitting(true);
+ setError(null);
+ const api = new ModerationApi();
+ const resp = await api.EditUserFeatureFlags(
+ usernameOrToken,
+ "ClearAllFlags",
+ );
+ setIsSubmitting(false);
+ setClearConfirmOpen(false);
+ if (resp.success) {
+ onSuccess?.();
+ onOpenChange(false);
+ } else {
+ setError(resp.errorMessage || "Failed to clear feature flags");
+ }
+ };
+
+ const selectedCount = selectedFlags.size;
+
+ return (
+ <>
+
+
+
+
+
+ Feature Flags
+
+
+ Manage feature flags for{" "}
+
+ {displayName}
+ {" "}
+ (@{username}).
+
+
+
+
+
+
+ setFilter(e.target.value)}
+ disabled={isLoading}
+ className="pl-9"
+ />
+
+
+
+
+ {isLoading
+ ? "Loading..."
+ : `${selectedCount} of ${allFlagRows.length} enabled`}
+
+ {!isLoading && initialUserFlags.length > 0 && (
+ setClearConfirmOpen(true)}
+ disabled={isSubmitting}
+ >
+
+ Clear all
+
+ )}
+
+
+
+ {isLoading ? (
+
+
+
+ ) : filteredFlags.length === 0 ? (
+
+ {allFlagRows.length === 0
+ ? "No feature flags available."
+ : "No flags match your filter."}
+
+ ) : (
+
+ {filteredFlags.map((flag) => {
+ const checked = selectedFlags.has(flag.key);
+ const isUnknown = !availableByKey.has(flag.key);
+ const Icon = getFeatureFlagIcon(flag);
+ return (
+
+
+
+ toggleFlag(flag.key, v === true)
+ }
+ />
+
+
+
+ {!isUnknown && flag.full_name && (
+
+ {flag.full_name}
+
+ )}
+
+ {flag.key}
+
+ {isUnknown && (
+
+ Unknown
+
+ )}
+
+ {flag.description && (
+
+ {flag.description}
+
+ )}
+
+
+
+ );
+ })}
+
+ )}
+
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+
+
+ onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ Cancel
+
+
+ {isSubmitting ? (
+
+ ) : (
+
+ )}
+ {isSubmitting ? "Saving..." : "Save Changes"}
+
+
+
+
+
+
+
+
+ Clear all feature flags?
+
+ This will remove every feature flag currently enabled for{" "}
+
+ {displayName}
+ {" "}
+ (@{username}). This action is recorded in the staff audit log.
+
+
+
+ Cancel
+ {
+ e.preventDefault();
+ handleClearAll();
+ }}
+ >
+ {isSubmitting ? (
+
+ ) : (
+
+ )}
+ {isSubmitting ? "Clearing..." : "Clear all flags"}
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/UserLookupPanel.tsx b/frontend/apps/artcraft-dashboard/src/components/UserLookupPanel.tsx
new file mode 100644
index 0000000000..09c7e6a3eb
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/UserLookupPanel.tsx
@@ -0,0 +1,157 @@
+import { useState } from "react";
+import { Link } from "react-router-dom";
+import {
+ ModerationApi,
+ type ModeratorUserLookupResponse,
+} from "@/api/ModerationApi";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Badge } from "@/components/ui/badge";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { getIdenticonUrl } from "@/lib/identicon";
+import {
+ IconSearch,
+ IconAlertCircle,
+ IconArrowRight,
+ IconUser,
+} from "@tabler/icons-react";
+
+function gravatarUrl(hash?: string): string | undefined {
+ return hash ? `https://gravatar.com/avatar/${hash}?s=60&d=404` : undefined;
+}
+
+interface UserLookupPanelProps {
+ title?: string;
+ user: ModeratorUserLookupResponse | null;
+ /** Override the email shown on the result card (e.g. after a change). */
+ displayEmail?: string | null;
+ onSelect: (user: ModeratorUserLookupResponse | null) => void;
+ /** Extra action buttons rendered on the result card, before the profile link. */
+ actions?: (user: ModeratorUserLookupResponse) => React.ReactNode;
+}
+
+export function UserLookupPanel({
+ title = "Find User",
+ user,
+ displayEmail,
+ onSelect,
+ actions,
+}: UserLookupPanelProps) {
+ const [searchQuery, setSearchQuery] = useState("");
+ const [isSearching, setIsSearching] = useState(false);
+ const [searchError, setSearchError] = useState(null);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ const query = searchQuery.trim();
+ if (!query || isSearching) return;
+
+ setIsSearching(true);
+ setSearchError(null);
+
+ const api = new ModerationApi();
+ const resp = await api.UserLookup(query);
+ setIsSearching(false);
+
+ if (resp.success && resp.data?.maybe_user) {
+ onSelect(resp.data.maybe_user);
+ } else {
+ onSelect(null);
+ setSearchError(resp.errorMessage || "User not found");
+ }
+ };
+
+ const email = displayEmail ?? user?.email_address;
+
+ return (
+
+
+
+ {title}
+
+
+
+
+ {isSearching && (
+
+ )}
+
+ {searchError && !isSearching && (
+
+
+
+ {searchError.charAt(0).toUpperCase() + searchError.slice(1)}
+
+
+ )}
+
+ {user && !isSearching && (
+
+
+
+
+
+
+
+
+
+
+ {user.display_name || user.username}
+
+ {user.is_banned && (
+
+ Banned
+
+ )}
+
+
+ @{user.username}
+
+ {email && (
+
+ {email}
+
+ )}
+
+
+ {actions?.(user)}
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/app-sidebar.tsx b/frontend/apps/artcraft-dashboard/src/components/app-sidebar.tsx
new file mode 100644
index 0000000000..bbb3a7bd13
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/app-sidebar.tsx
@@ -0,0 +1,239 @@
+import * as React from "react";
+import {
+ IconBrandStripe,
+ IconBriefcase,
+ IconBug,
+ IconCamera,
+ IconCompass,
+ IconCreditCard,
+ IconDashboard,
+ IconFlag,
+ IconKey,
+ IconClipboardList,
+ IconMail,
+ IconBellRinging,
+ IconFileAi,
+ IconFileDescription,
+ IconSettings,
+ IconShare,
+ IconUserPlus,
+ IconUsers,
+} from "@tabler/icons-react";
+//import { NavDocuments } from "@/components/nav-documents";
+import { NavMain } from "@/components/nav-main";
+import { NavSecondary } from "@/components/nav-secondary";
+import { NavUser } from "@/components/nav-user";
+import { SettingsDialog } from "@/components/settings-dialog";
+import artcraftIcon from "@/assets/artcraft-icon.svg";
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarHeader,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+} from "@/components/ui/sidebar";
+import { Link } from "react-router-dom";
+
+const data = {
+ navMain: [
+ {
+ title: "Dashboard",
+ url: "/",
+ icon: IconDashboard,
+ },
+ {
+ title: "Explore Media",
+ url: "/explore/media",
+ icon: IconCompass,
+ },
+ {
+ title: "User Search",
+ url: "/user/search",
+ icon: IconUsers,
+ },
+ {
+ title: "Stripe Lookup",
+ url: "/stripe-lookup",
+ icon: IconBrandStripe,
+ },
+ {
+ title: "Search Job by Token",
+ url: "/moderation/job-search",
+ icon: IconBriefcase,
+ },
+ {
+ title: "Search Debug Logs",
+ url: "/moderation/debug-logs-search",
+ icon: IconBug,
+ },
+ {
+ title: "User Signups",
+ url: "/user-signups",
+ icon: IconUserPlus,
+ },
+ {
+ title: "Subscriber Signups",
+ url: "/subscriber-signups",
+ icon: IconCreditCard,
+ },
+ {
+ title: "Impersonation Logs",
+ url: "/impersonation",
+ icon: IconKey,
+ },
+ {
+ title: "Staff Audit Logs",
+ url: "/staff-audit-logs",
+ icon: IconClipboardList,
+ },
+ {
+ title: "Send Pager",
+ url: "/send-pager",
+ icon: IconBellRinging,
+ },
+ {
+ title: "Feature Flags",
+ url: "/feature-flags",
+ icon: IconFlag,
+ },
+ {
+ title: "Email Changes",
+ url: "/email-changes",
+ icon: IconMail,
+ },
+ {
+ title: "Referrals",
+ url: "/referrals",
+ icon: IconShare,
+ },
+ ],
+ navClouds: [
+ {
+ title: "Capture",
+ icon: IconCamera,
+ isActive: true,
+ url: "#",
+ items: [
+ {
+ title: "Active Proposals",
+ url: "#",
+ },
+ {
+ title: "Archived",
+ url: "#",
+ },
+ ],
+ },
+ {
+ title: "Proposal",
+ icon: IconFileDescription,
+ url: "#",
+ items: [
+ {
+ title: "Active Proposals",
+ url: "#",
+ },
+ {
+ title: "Archived",
+ url: "#",
+ },
+ ],
+ },
+ {
+ title: "Prompts",
+ icon: IconFileAi,
+ url: "#",
+ items: [
+ {
+ title: "Active Proposals",
+ url: "#",
+ },
+ {
+ title: "Archived",
+ url: "#",
+ },
+ ],
+ },
+ ],
+ navSecondary: [
+ {
+ title: "Settings",
+ url: "#",
+ icon: IconSettings,
+ },
+ ],
+ // documents: [
+ // {
+ // name: "Data Library",
+ // url: "#",
+ // icon: IconDatabase,
+ // },
+ // {
+ // name: "Reports",
+ // url: "#",
+ // icon: IconReport,
+ // },
+ // {
+ // name: "Word Assistant",
+ // url: "#",
+ // icon: IconFileWord,
+ // },
+ // ],
+};
+
+export function AppSidebar({ ...props }: React.ComponentProps) {
+ const [settingsOpen, setSettingsOpen] = React.useState(false);
+
+ const navSecondaryConfig = React.useMemo(
+ () =>
+ data.navSecondary.map((item) => {
+ if (item.title === "Settings") {
+ return {
+ ...item,
+ onClick: () => setSettingsOpen(true),
+ };
+ }
+ return item;
+ }),
+ [],
+ );
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ ArtCraft Admin
+
+
+
+
+
+
+
+
+ {/* */}
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/chart-area-interactive.tsx b/frontend/apps/artcraft-dashboard/src/components/chart-area-interactive.tsx
new file mode 100644
index 0000000000..38b954c000
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/chart-area-interactive.tsx
@@ -0,0 +1,291 @@
+"use client"
+
+import * as React from "react"
+import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
+
+import { useIsMobile } from "@/hooks/use-mobile"
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ type ChartConfig,
+} from "@/components/ui/chart"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ ToggleGroup,
+ ToggleGroupItem,
+} from "@/components/ui/toggle-group"
+
+export const description = "An interactive area chart"
+
+const chartData = [
+ { date: "2024-04-01", desktop: 222, mobile: 150 },
+ { date: "2024-04-02", desktop: 97, mobile: 180 },
+ { date: "2024-04-03", desktop: 167, mobile: 120 },
+ { date: "2024-04-04", desktop: 242, mobile: 260 },
+ { date: "2024-04-05", desktop: 373, mobile: 290 },
+ { date: "2024-04-06", desktop: 301, mobile: 340 },
+ { date: "2024-04-07", desktop: 245, mobile: 180 },
+ { date: "2024-04-08", desktop: 409, mobile: 320 },
+ { date: "2024-04-09", desktop: 59, mobile: 110 },
+ { date: "2024-04-10", desktop: 261, mobile: 190 },
+ { date: "2024-04-11", desktop: 327, mobile: 350 },
+ { date: "2024-04-12", desktop: 292, mobile: 210 },
+ { date: "2024-04-13", desktop: 342, mobile: 380 },
+ { date: "2024-04-14", desktop: 137, mobile: 220 },
+ { date: "2024-04-15", desktop: 120, mobile: 170 },
+ { date: "2024-04-16", desktop: 138, mobile: 190 },
+ { date: "2024-04-17", desktop: 446, mobile: 360 },
+ { date: "2024-04-18", desktop: 364, mobile: 410 },
+ { date: "2024-04-19", desktop: 243, mobile: 180 },
+ { date: "2024-04-20", desktop: 89, mobile: 150 },
+ { date: "2024-04-21", desktop: 137, mobile: 200 },
+ { date: "2024-04-22", desktop: 224, mobile: 170 },
+ { date: "2024-04-23", desktop: 138, mobile: 230 },
+ { date: "2024-04-24", desktop: 387, mobile: 290 },
+ { date: "2024-04-25", desktop: 215, mobile: 250 },
+ { date: "2024-04-26", desktop: 75, mobile: 130 },
+ { date: "2024-04-27", desktop: 383, mobile: 420 },
+ { date: "2024-04-28", desktop: 122, mobile: 180 },
+ { date: "2024-04-29", desktop: 315, mobile: 240 },
+ { date: "2024-04-30", desktop: 454, mobile: 380 },
+ { date: "2024-05-01", desktop: 165, mobile: 220 },
+ { date: "2024-05-02", desktop: 293, mobile: 310 },
+ { date: "2024-05-03", desktop: 247, mobile: 190 },
+ { date: "2024-05-04", desktop: 385, mobile: 420 },
+ { date: "2024-05-05", desktop: 481, mobile: 390 },
+ { date: "2024-05-06", desktop: 498, mobile: 520 },
+ { date: "2024-05-07", desktop: 388, mobile: 300 },
+ { date: "2024-05-08", desktop: 149, mobile: 210 },
+ { date: "2024-05-09", desktop: 227, mobile: 180 },
+ { date: "2024-05-10", desktop: 293, mobile: 330 },
+ { date: "2024-05-11", desktop: 335, mobile: 270 },
+ { date: "2024-05-12", desktop: 197, mobile: 240 },
+ { date: "2024-05-13", desktop: 197, mobile: 160 },
+ { date: "2024-05-14", desktop: 448, mobile: 490 },
+ { date: "2024-05-15", desktop: 473, mobile: 380 },
+ { date: "2024-05-16", desktop: 338, mobile: 400 },
+ { date: "2024-05-17", desktop: 499, mobile: 420 },
+ { date: "2024-05-18", desktop: 315, mobile: 350 },
+ { date: "2024-05-19", desktop: 235, mobile: 180 },
+ { date: "2024-05-20", desktop: 177, mobile: 230 },
+ { date: "2024-05-21", desktop: 82, mobile: 140 },
+ { date: "2024-05-22", desktop: 81, mobile: 120 },
+ { date: "2024-05-23", desktop: 252, mobile: 290 },
+ { date: "2024-05-24", desktop: 294, mobile: 220 },
+ { date: "2024-05-25", desktop: 201, mobile: 250 },
+ { date: "2024-05-26", desktop: 213, mobile: 170 },
+ { date: "2024-05-27", desktop: 420, mobile: 460 },
+ { date: "2024-05-28", desktop: 233, mobile: 190 },
+ { date: "2024-05-29", desktop: 78, mobile: 130 },
+ { date: "2024-05-30", desktop: 340, mobile: 280 },
+ { date: "2024-05-31", desktop: 178, mobile: 230 },
+ { date: "2024-06-01", desktop: 178, mobile: 200 },
+ { date: "2024-06-02", desktop: 470, mobile: 410 },
+ { date: "2024-06-03", desktop: 103, mobile: 160 },
+ { date: "2024-06-04", desktop: 439, mobile: 380 },
+ { date: "2024-06-05", desktop: 88, mobile: 140 },
+ { date: "2024-06-06", desktop: 294, mobile: 250 },
+ { date: "2024-06-07", desktop: 323, mobile: 370 },
+ { date: "2024-06-08", desktop: 385, mobile: 320 },
+ { date: "2024-06-09", desktop: 438, mobile: 480 },
+ { date: "2024-06-10", desktop: 155, mobile: 200 },
+ { date: "2024-06-11", desktop: 92, mobile: 150 },
+ { date: "2024-06-12", desktop: 492, mobile: 420 },
+ { date: "2024-06-13", desktop: 81, mobile: 130 },
+ { date: "2024-06-14", desktop: 426, mobile: 380 },
+ { date: "2024-06-15", desktop: 307, mobile: 350 },
+ { date: "2024-06-16", desktop: 371, mobile: 310 },
+ { date: "2024-06-17", desktop: 475, mobile: 520 },
+ { date: "2024-06-18", desktop: 107, mobile: 170 },
+ { date: "2024-06-19", desktop: 341, mobile: 290 },
+ { date: "2024-06-20", desktop: 408, mobile: 450 },
+ { date: "2024-06-21", desktop: 169, mobile: 210 },
+ { date: "2024-06-22", desktop: 317, mobile: 270 },
+ { date: "2024-06-23", desktop: 480, mobile: 530 },
+ { date: "2024-06-24", desktop: 132, mobile: 180 },
+ { date: "2024-06-25", desktop: 141, mobile: 190 },
+ { date: "2024-06-26", desktop: 434, mobile: 380 },
+ { date: "2024-06-27", desktop: 448, mobile: 490 },
+ { date: "2024-06-28", desktop: 149, mobile: 200 },
+ { date: "2024-06-29", desktop: 103, mobile: 160 },
+ { date: "2024-06-30", desktop: 446, mobile: 400 },
+]
+
+const chartConfig = {
+ visitors: {
+ label: "Visitors",
+ },
+ desktop: {
+ label: "Desktop",
+ color: "var(--primary)",
+ },
+ mobile: {
+ label: "Mobile",
+ color: "var(--primary)",
+ },
+} satisfies ChartConfig
+
+export function ChartAreaInteractive() {
+ const isMobile = useIsMobile()
+ const [timeRange, setTimeRange] = React.useState("90d")
+
+ React.useEffect(() => {
+ if (isMobile) {
+ setTimeRange("7d")
+ }
+ }, [isMobile])
+
+ const filteredData = chartData.filter((item) => {
+ const date = new Date(item.date)
+ const referenceDate = new Date("2024-06-30")
+ let daysToSubtract = 90
+ if (timeRange === "30d") {
+ daysToSubtract = 30
+ } else if (timeRange === "7d") {
+ daysToSubtract = 7
+ }
+ const startDate = new Date(referenceDate)
+ startDate.setDate(startDate.getDate() - daysToSubtract)
+ return date >= startDate
+ })
+
+ return (
+
+
+ Total Visitors
+
+
+ Total for the last 3 months
+
+ Last 3 months
+
+
+
+ Last 3 months
+ Last 30 days
+ Last 7 days
+
+
+
+
+
+
+
+ Last 3 months
+
+
+ Last 30 days
+
+
+ Last 7 days
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ const date = new Date(value)
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ })
+ }}
+ />
+ {
+ return new Date(value).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ })
+ }}
+ indicator="dot"
+ />
+ }
+ />
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/data-table.tsx b/frontend/apps/artcraft-dashboard/src/components/data-table.tsx
new file mode 100644
index 0000000000..c05afc1b6d
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/data-table.tsx
@@ -0,0 +1,805 @@
+import * as React from "react"
+import {
+ closestCenter,
+ DndContext,
+ KeyboardSensor,
+ MouseSensor,
+ TouchSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+ type UniqueIdentifier,
+} from "@dnd-kit/core"
+import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
+import {
+ arrayMove,
+ SortableContext,
+ useSortable,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable"
+import { CSS } from "@dnd-kit/utilities"
+import {
+ IconChevronDown,
+ IconChevronLeft,
+ IconChevronRight,
+ IconChevronsLeft,
+ IconChevronsRight,
+ IconCircleCheckFilled,
+ IconDotsVertical,
+ IconGripVertical,
+ IconLayoutColumns,
+ IconLoader,
+ IconPlus,
+ IconTrendingUp,
+} from "@tabler/icons-react"
+import {
+ flexRender,
+ getCoreRowModel,
+ getFacetedRowModel,
+ getFacetedUniqueValues,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+ type ColumnDef,
+ type ColumnFiltersState,
+ type Row,
+ type SortingState,
+ type VisibilityState,
+} from "@tanstack/react-table"
+import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
+import { toast } from "sonner"
+import { z } from "zod"
+
+import { useIsMobile } from "@/hooks/use-mobile"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ ChartContainer,
+ ChartTooltip,
+ ChartTooltipContent,
+ type ChartConfig,
+} from "@/components/ui/chart"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Separator } from "@/components/ui/separator"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@/components/ui/tabs"
+
+export const schema = z.object({
+ id: z.number(),
+ header: z.string(),
+ type: z.string(),
+ status: z.string(),
+ target: z.string(),
+ limit: z.string(),
+ reviewer: z.string(),
+})
+
+// Create a separate component for the drag handle
+function DragHandle({ id }: { id: number }) {
+ const { attributes, listeners } = useSortable({
+ id,
+ })
+
+ return (
+
+
+ Drag to reorder
+
+ )
+}
+
+const columns: ColumnDef>[] = [
+ {
+ id: "drag",
+ header: () => null,
+ cell: ({ row }) => ,
+ },
+ {
+ id: "select",
+ header: ({ table }) => (
+
+ table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+
+ ),
+ cell: ({ row }) => (
+
+ row.toggleSelected(!!value)}
+ aria-label="Select row"
+ />
+
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: "header",
+ header: "Header",
+ cell: ({ row }) => {
+ return
+ },
+ enableHiding: false,
+ },
+ {
+ accessorKey: "type",
+ header: "Section Type",
+ cell: ({ row }) => (
+
+
+ {row.original.type}
+
+
+ ),
+ },
+ {
+ accessorKey: "status",
+ header: "Status",
+ cell: ({ row }) => (
+
+ {row.original.status === "Done" ? (
+
+ ) : (
+
+ )}
+ {row.original.status}
+
+ ),
+ },
+ {
+ accessorKey: "target",
+ header: () => Target
,
+ cell: ({ row }) => (
+
+ ),
+ },
+ {
+ accessorKey: "limit",
+ header: () => Limit
,
+ cell: ({ row }) => (
+
+ ),
+ },
+ {
+ accessorKey: "reviewer",
+ header: "Reviewer",
+ cell: ({ row }) => {
+ const isAssigned = row.original.reviewer !== "Assign reviewer"
+
+ if (isAssigned) {
+ return row.original.reviewer
+ }
+
+ return (
+ <>
+
+ Reviewer
+
+
+
+
+
+
+ Eddie Lake
+
+ Jamik Tashpulatov
+
+
+
+ >
+ )
+ },
+ },
+ {
+ id: "actions",
+ cell: () => (
+
+
+
+
+ Open menu
+
+
+
+ Edit
+ Make a copy
+ Favorite
+
+ Delete
+
+
+ ),
+ },
+]
+
+function DraggableRow({ row }: { row: Row> }) {
+ const { transform, transition, setNodeRef, isDragging } = useSortable({
+ id: row.original.id,
+ })
+
+ return (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ )
+}
+
+export function DataTable({
+ data: initialData,
+}: {
+ data: z.infer[]
+}) {
+ const [data, setData] = React.useState(() => initialData)
+ const [rowSelection, setRowSelection] = React.useState({})
+ const [columnVisibility, setColumnVisibility] =
+ React.useState({})
+ const [columnFilters, setColumnFilters] = React.useState(
+ []
+ )
+ const [sorting, setSorting] = React.useState([])
+ const [pagination, setPagination] = React.useState({
+ pageIndex: 0,
+ pageSize: 10,
+ })
+ const sortableId = React.useId()
+ const sensors = useSensors(
+ useSensor(MouseSensor, {}),
+ useSensor(TouchSensor, {}),
+ useSensor(KeyboardSensor, {})
+ )
+
+ const dataIds = React.useMemo(
+ () => data?.map(({ id }) => id) || [],
+ [data]
+ )
+
+ const table = useReactTable({
+ data,
+ columns,
+ state: {
+ sorting,
+ columnVisibility,
+ rowSelection,
+ columnFilters,
+ pagination,
+ },
+ getRowId: (row) => row.id.toString(),
+ enableRowSelection: true,
+ onRowSelectionChange: setRowSelection,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onPaginationChange: setPagination,
+ getCoreRowModel: getCoreRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFacetedRowModel: getFacetedRowModel(),
+ getFacetedUniqueValues: getFacetedUniqueValues(),
+ })
+
+ function handleDragEnd(event: DragEndEvent) {
+ const { active, over } = event
+ if (active && over && active.id !== over.id) {
+ setData((data) => {
+ const oldIndex = dataIds.indexOf(active.id)
+ const newIndex = dataIds.indexOf(over.id)
+ return arrayMove(data, oldIndex, newIndex)
+ })
+ }
+ }
+
+ return (
+
+
+
+ View
+
+
+
+
+
+
+ Outline
+ Past Performance
+ Key Personnel
+ Focus Documents
+
+
+
+ Outline
+
+ Past Performance 3
+
+
+ Key Personnel 2
+
+ Focus Documents
+
+
+
+
+
+
+ Customize Columns
+ Columns
+
+
+
+
+ {table
+ .getAllColumns()
+ .filter(
+ (column) =>
+ typeof column.accessorFn !== "undefined" &&
+ column.getCanHide()
+ )
+ .map((column) => {
+ return (
+
+ column.toggleVisibility(!!value)
+ }
+ >
+ {column.id}
+
+ )
+ })}
+
+
+
+
+ Add Section
+
+
+
+
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ return (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+
+ )
+ })}
+
+ ))}
+
+
+ {table.getRowModel().rows?.length ? (
+
+ {table.getRowModel().rows.map((row) => (
+
+ ))}
+
+ ) : (
+
+
+ No results.
+
+
+ )}
+
+
+
+
+
+
+ {table.getFilteredSelectedRowModel().rows.length} of{" "}
+ {table.getFilteredRowModel().rows.length} row(s) selected.
+
+
+
+
+ Rows per page
+
+ {
+ table.setPageSize(Number(value))
+ }}
+ >
+
+
+
+
+ {[10, 20, 30, 40, 50].map((pageSize) => (
+
+ {pageSize}
+
+ ))}
+
+
+
+
+ Page {table.getState().pagination.pageIndex + 1} of{" "}
+ {table.getPageCount()}
+
+
+ table.setPageIndex(0)}
+ disabled={!table.getCanPreviousPage()}
+ >
+ Go to first page
+
+
+ table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ Go to previous page
+
+
+ table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ Go to next page
+
+
+ table.setPageIndex(table.getPageCount() - 1)}
+ disabled={!table.getCanNextPage()}
+ >
+ Go to last page
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+const chartData = [
+ { month: "January", desktop: 186, mobile: 80 },
+ { month: "February", desktop: 305, mobile: 200 },
+ { month: "March", desktop: 237, mobile: 120 },
+ { month: "April", desktop: 73, mobile: 190 },
+ { month: "May", desktop: 209, mobile: 130 },
+ { month: "June", desktop: 214, mobile: 140 },
+]
+
+const chartConfig = {
+ desktop: {
+ label: "Desktop",
+ color: "var(--primary)",
+ },
+ mobile: {
+ label: "Mobile",
+ color: "var(--primary)",
+ },
+} satisfies ChartConfig
+
+function TableCellViewer({ item }: { item: z.infer }) {
+ const isMobile = useIsMobile()
+
+ return (
+
+
+
+ {item.header}
+
+
+
+
+ {item.header}
+
+ Showing total visitors for the last 6 months
+
+
+
+ {!isMobile && (
+ <>
+
+
+
+ value.slice(0, 3)}
+ hide
+ />
+ }
+ />
+
+
+
+
+
+
+
+ Trending up by 5.2% this month{" "}
+
+
+
+ Showing total visitors for the last 6 months. This is just
+ some random text to test the layout. It spans multiple lines
+ and should wrap around.
+
+
+
+ >
+ )}
+
+
+
+ Submit
+
+ Done
+
+
+
+
+ )
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/nav-documents.tsx b/frontend/apps/artcraft-dashboard/src/components/nav-documents.tsx
new file mode 100644
index 0000000000..7e586a71ab
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/nav-documents.tsx
@@ -0,0 +1,92 @@
+"use client"
+
+import {
+ IconDots,
+ IconFolder,
+ IconShare3,
+ IconTrash,
+ type Icon,
+} from "@tabler/icons-react"
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import {
+ SidebarGroup,
+ SidebarGroupLabel,
+ SidebarMenu,
+ SidebarMenuAction,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ useSidebar,
+} from "@/components/ui/sidebar"
+
+export function NavDocuments({
+ items,
+}: {
+ items: {
+ name: string
+ url: string
+ icon: Icon
+ }[]
+}) {
+ const { isMobile } = useSidebar()
+
+ return (
+
+ Documents
+
+ {items.map((item) => (
+
+
+
+
+ {item.name}
+
+
+
+
+
+
+ More
+
+
+
+
+
+ Open
+
+
+
+ Share
+
+
+
+
+ Delete
+
+
+
+
+ ))}
+
+
+
+ More
+
+
+
+
+ )
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/nav-main.tsx b/frontend/apps/artcraft-dashboard/src/components/nav-main.tsx
new file mode 100644
index 0000000000..5c5e724f9d
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/nav-main.tsx
@@ -0,0 +1,54 @@
+import { Link, useLocation } from "react-router-dom";
+import { type Icon } from "@tabler/icons-react";
+
+import {
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ useSidebar,
+} from "@/components/ui/sidebar";
+
+export function NavMain({
+ items,
+}: {
+ items: {
+ title: string;
+ url: string;
+ icon?: Icon;
+ }[];
+}) {
+ const { pathname } = useLocation();
+ const { isMobile, setOpenMobile } = useSidebar();
+
+ const handleClick = () => {
+ if (isMobile) {
+ setOpenMobile(false);
+ }
+ };
+
+ return (
+
+
+
+ {items.map((item) => (
+
+
+
+ {item.icon && }
+ {item.title}
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/nav-secondary.tsx b/frontend/apps/artcraft-dashboard/src/components/nav-secondary.tsx
new file mode 100644
index 0000000000..2dab05c950
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/nav-secondary.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import * as React from "react";
+import { type Icon } from "@tabler/icons-react";
+
+import {
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ useSidebar,
+} from "@/components/ui/sidebar";
+
+export function NavSecondary({
+ items,
+ ...props
+}: {
+ items: {
+ title: string;
+ url: string;
+ icon: Icon;
+ onClick?: (e: React.MouseEvent) => void;
+ }[];
+} & React.ComponentPropsWithoutRef) {
+ const { isMobile, setOpenMobile } = useSidebar();
+
+ const handleClick = (
+ e: React.MouseEvent,
+ itemOnClick?: (e: React.MouseEvent) => void,
+ ) => {
+ if (itemOnClick) {
+ e.preventDefault();
+ itemOnClick(e);
+ }
+ if (isMobile) {
+ setOpenMobile(false);
+ }
+ };
+
+ return (
+
+
+
+ {items.map((item) => (
+
+ handleClick(e, item.onClick)}
+ >
+
+
+ {item.title}
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/nav-user.tsx b/frontend/apps/artcraft-dashboard/src/components/nav-user.tsx
new file mode 100644
index 0000000000..8a1e61646c
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/nav-user.tsx
@@ -0,0 +1,112 @@
+import {
+ IconDotsVertical,
+ IconLogout,
+ IconSun,
+ IconMoon,
+} from "@tabler/icons-react";
+import { useTheme } from "next-themes";
+
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { getIdenticonUrl } from "@/lib/identicon";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ useSidebar,
+} from "@/components/ui/sidebar";
+import { useAuth } from "@/context/AuthContext";
+import { UsersApi } from "@/api/UsersApi";
+
+export function NavUser() {
+ const { isMobile } = useSidebar();
+ const { user, logoutState } = useAuth();
+ const { theme, setTheme } = useTheme();
+
+ if (!user) return null;
+
+ const displayName = user.display_name || user.username;
+ const avatarSrc = user.gravatar_url || user.avatar_url;
+ const identiconSrc = getIdenticonUrl(user.username || user.email);
+
+ const handleLogout = async () => {
+ try {
+ const api = new UsersApi();
+ await api.Logout();
+ } catch {
+ // proceed with local logout regardless
+ }
+ logoutState();
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {displayName}
+
+ {user.email}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {displayName}
+
+ {user.email}
+
+
+
+
+
+ setTheme(theme === "dark" ? "light" : "dark")}
+ >
+ {theme === "dark" ? : }
+ Toggle {theme === "dark" ? "light mode" : "dark mode"}
+
+
+
+
+ Log out
+
+
+
+
+
+ );
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/section-cards.tsx b/frontend/apps/artcraft-dashboard/src/components/section-cards.tsx
new file mode 100644
index 0000000000..375809d282
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/section-cards.tsx
@@ -0,0 +1,102 @@
+import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"
+
+import { Badge } from "@/components/ui/badge"
+import {
+ Card,
+ CardAction,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+
+export function SectionCards() {
+ return (
+
+
+
+ Total Revenue
+
+ $1,250.00
+
+
+
+
+ +12.5%
+
+
+
+
+
+ Trending up this month
+
+
+ Visitors for the last 6 months
+
+
+
+
+
+ New Customers
+
+ 1,234
+
+
+
+
+ -20%
+
+
+
+
+
+ Down 20% this period
+
+
+ Acquisition needs attention
+
+
+
+
+
+ Active Accounts
+
+ 45,678
+
+
+
+
+ +12.5%
+
+
+
+
+
+ Strong user retention
+
+ Engagement exceed targets
+
+
+
+
+ Growth Rate
+
+ 4.5%
+
+
+
+
+ +4.5%
+
+
+
+
+
+ Steady performance increase
+
+ Meets growth projections
+
+
+
+ )
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/settings-dialog.tsx b/frontend/apps/artcraft-dashboard/src/components/settings-dialog.tsx
new file mode 100644
index 0000000000..5424d7da27
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/settings-dialog.tsx
@@ -0,0 +1,81 @@
+import { IconSettings } from "@tabler/icons-react";
+import { useTheme } from "next-themes";
+
+import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
+import { Switch } from "@/components/ui/switch";
+import { Label } from "@/components/ui/label";
+import { cn } from "@/lib/utils";
+
+export function SettingsDialog({
+ open,
+ onOpenChange,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}) {
+ const { theme, setTheme } = useTheme();
+
+ return (
+
+
+ Settings
+
+ {/* Left Sidebar / Top Nav on mobile */}
+
+
+
+ Settings
+
+
+
+ General
+
+
+
+
+ {/* Right Content */}
+
+
+
+
General
+
+ Choose how you want your dashboard to look and behave.
+
+
+
+
+
+
+ Appearance
+
+
+
+
+
+ Dark Mode
+
+
+ Enable dark theme for the dashboard
+
+
+
+ setTheme(checked ? "dark" : "light")
+ }
+ />
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/site-header.tsx b/frontend/apps/artcraft-dashboard/src/components/site-header.tsx
new file mode 100644
index 0000000000..59c4f02870
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/site-header.tsx
@@ -0,0 +1,30 @@
+import { Button } from "@/components/ui/button"
+import { Separator } from "@/components/ui/separator"
+import { SidebarTrigger } from "@/components/ui/sidebar"
+
+export function SiteHeader() {
+ return (
+
+ )
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/theme-provider.tsx b/frontend/apps/artcraft-dashboard/src/components/theme-provider.tsx
new file mode 100644
index 0000000000..713db7e9f5
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/theme-provider.tsx
@@ -0,0 +1,6 @@
+import { ThemeProvider as NextThemesProvider } from "next-themes";
+import { type ThemeProviderProps } from "next-themes";
+
+export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
+ return {children} ;
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/ui/alert-dialog.tsx b/frontend/apps/artcraft-dashboard/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000000..17e2c09b0c
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,196 @@
+import * as React from "react"
+import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { Button } 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,
+ size = "default",
+ overlayClassName,
+ ...props
+}: React.ComponentProps & {
+ size?: "default" | "sm"
+ overlayClassName?: string
+}) {
+ 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 AlertDialogMedia({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogAction({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: React.ComponentProps &
+ Pick, "variant" | "size">) {
+ return (
+
+
+
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ variant = "outline",
+ size = "default",
+ ...props
+}: React.ComponentProps &
+ Pick, "variant" | "size">) {
+ return (
+
+
+
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/ui/alert.tsx b/frontend/apps/artcraft-dashboard/src/components/ui/alert.tsx
new file mode 100644
index 0000000000..72e93da14c
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/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 grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
+ {
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive:
+ "border-destructive/50 bg-destructive/10 text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
+ },
+ },
+ 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/frontend/apps/artcraft-dashboard/src/components/ui/avatar.tsx b/frontend/apps/artcraft-dashboard/src/components/ui/avatar.tsx
new file mode 100644
index 0000000000..7f05ce45e0
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/ui/avatar.tsx
@@ -0,0 +1,107 @@
+import * as React from "react";
+import { Avatar as AvatarPrimitive } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+function Avatar({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps & {
+ size?: "default" | "sm" | "lg";
+}) {
+ return (
+
+ );
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+ svg]:hidden",
+ "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
+ "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AvatarGroupCount({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export {
+ Avatar,
+ AvatarImage,
+ AvatarFallback,
+ AvatarBadge,
+ AvatarGroup,
+ AvatarGroupCount,
+};
diff --git a/frontend/apps/artcraft-dashboard/src/components/ui/badge.tsx b/frontend/apps/artcraft-dashboard/src/components/ui/badge.tsx
new file mode 100644
index 0000000000..6eb2a057aa
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/ui/badge.tsx
@@ -0,0 +1,48 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
+ outline:
+ "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 [a&]:hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant = "default",
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> &
+ VariantProps
& { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : "span"
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/frontend/apps/artcraft-dashboard/src/components/ui/breadcrumb.tsx b/frontend/apps/artcraft-dashboard/src/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000000..004bb63a4c
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/ui/breadcrumb.tsx
@@ -0,0 +1,109 @@
+import * as React from "react"
+import { ChevronRight, MoreHorizontal } from "lucide-react"
+import { Slot } from "radix-ui"
+
+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.Root : "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/frontend/apps/artcraft-dashboard/src/components/ui/button.tsx b/frontend/apps/artcraft-dashboard/src/components/ui/button.tsx
new file mode 100644
index 0000000000..c25f3c1f15
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/ui/button.tsx
@@ -0,0 +1,64 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { Slot } from "radix-ui";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "inline-flex shrink-0 items-center justify-center gap-2 rounded-md cursor-pointer text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ {
+ 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:bg-destructive/60 dark:hover:bg-destructive/80 dark:focus-visible:ring-destructive/40",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost:
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-10 px-4 py-2 has-[>svg]:px-3",
+ xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
+ lg: "h-12 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+function Button({
+ className,
+ variant = "default",
+ size = "default",
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean;
+ }) {
+ const Comp = asChild ? Slot.Root : "button";
+
+ return (
+
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/frontend/apps/artcraft-dashboard/src/components/ui/card.tsx b/frontend/apps/artcraft-dashboard/src/components/ui/card.tsx
new file mode 100644
index 0000000000..acf57dc5a5
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/ui/card.tsx
@@ -0,0 +1,92 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/frontend/apps/artcraft-dashboard/src/components/ui/chart.tsx b/frontend/apps/artcraft-dashboard/src/components/ui/chart.tsx
new file mode 100644
index 0000000000..88dfd1dbf2
--- /dev/null
+++ b/frontend/apps/artcraft-dashboard/src/components/ui/chart.tsx
@@ -0,0 +1,355 @@
+import * as React from "react"
+import * as RechartsPrimitive from "recharts"
+
+import { cn } from "@/lib/utils"
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const
+
+export type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode
+ icon?: React.ComponentType
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record }
+ )
+}
+
+type ChartContextProps = {
+ config: ChartConfig
+}
+
+const ChartContext = React.createContext(null)
+
+function useChart() {
+ const context = React.useContext(ChartContext)
+
+ if (!context) {
+ throw new Error("useChart must be used within a ")
+ }
+
+ return context
+}
+
+function ChartContainer({
+ id,
+ className,
+ children,
+ config,
+ ...props
+}: React.ComponentProps<"div"> & {
+ config: ChartConfig
+ children: React.ComponentProps<
+ typeof RechartsPrimitive.ResponsiveContainer
+ >["children"]
+}) {
+ const uniqueId = React.useId()
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ )
+}
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(
+ ([, config]) => config.theme || config.color
+ )
+
+ if (!colorConfig.length) {
+ return null
+ }
+
+ return (
+