Compare commits

...
Author SHA1 Message Date
bflatastic 0aa88abcd2 revert dashboard merge 2026-06-23 21:24:49 +07:00
112 changed files with 8633 additions and 21219 deletions
-23
View File
@@ -1,23 +0,0 @@
{
"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
}
}
@@ -1,2 +0,0 @@
# ArtCraft Admin Dashboard
Admin dashboard for managing ArtCraft
@@ -1,41 +0,0 @@
# 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
@@ -1,24 +0,0 @@
{
"$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": {}
}
@@ -1,23 +0,0 @@
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,
},
},
])
@@ -1,19 +0,0 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/artcraft-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&display=swap"
rel="stylesheet"
/>
<title>ArtCraft Admin Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -1,15 +0,0 @@
# 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"
@@ -1,46 +0,0 @@
{
"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": "^19.2.0",
"react-dom": "^19.2.0",
"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",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.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"
}
}
@@ -1,2 +0,0 @@
/api/* https://api.storyteller.ai/:splat 200
/* /index.html 200
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72.22 65.68">
<defs>
<style>
.cls-1 {
fill: #2d81ff;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<path class="cls-1" d="M69.38,45.46c-3.18-6.24-6.58-12.49-9.86-18.53-3.31-6.08-6.72-12.37-9.91-18.63l-.05-.09C46.44,3.07,41.24,0,35.66,0s-10.34,3-13.13,8.23L.97,48.96l-.03.07c-1.51,3.8-1.2,8.03.82,11.31,1.9,3.08,5.01,4.96,8.76,5.28.38.03.75.05,1.1.05,2.94,0,4.98-1.12,7.33-2.41,7.13-3.91,19.45-11.21,27.61-16.04,2.6-1.54,4.95-2.93,6.31-3.73l10.71,20.43c.2.39.54.7.95.86.46.19.98.2,1.43,0,1.39-.66,3.05-2.47,3.07-2.49,5.22-5.9,3.03-11.55.34-16.83ZM34.53,14.8c.31-.46.77-.99,1.32-.99.58,0,1.29.57,1.85,1.49,1.57,2.56,2.99,5.44,4.35,8.22,1.34,2.73,2.72,5.55,4.29,8.15l.02.05-29.6,16.99L34.53,14.8Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 885 B

@@ -1,17 +0,0 @@
#!/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
@@ -1,42 +0,0 @@
#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;
}
@@ -1,103 +0,0 @@
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 (
<div className="flex h-screen w-full items-center justify-center">
<Spinner className="size-12 opacity-60" />
</div>
);
}
return (
<Routes>
<Route
path="/login"
element={user ? <Navigate to={redirectTo} replace /> : <LoginPage />}
/>
<Route
path="/"
element={user ? <DashboardLayout /> : <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname)}`} replace />}
>
<Route index element={<DashboardHome />} />
<Route path="user/search" element={<UserSearch />} />
<Route path="user/profile/:username" element={<UserProfile />} />
<Route path="user/profile/:username/wallet" element={<WalletHistory />} />
<Route path="user/profile/:username/jobs" element={<JobHistory />} />
<Route path="user/profile/:username/creations" element={<UserCreations />} />
<Route path="user/profile/:username/referrals" element={<UserReferrals />} />
<Route path="stripe-lookup" element={<StripeLookup />} />
<Route path="user-signups" element={<UserSignups />} />
<Route path="subscriber-signups" element={<SubscriberSignups />} />
<Route path="impersonation" element={<Impersonation />} />
<Route path="staff-audit-logs" element={<StaffAuditLogs />} />
<Route path="send-pager" element={<SendPager />} />
<Route path="feature-flags" element={<FeatureFlags />} />
<Route path="email-changes" element={<UserEmailChanges />} />
<Route path="referrals" element={<ReferralsAnalytics />} />
<Route path="explore/media" element={<ExploreMedia />} />
<Route path="explore/media/:mediaToken" element={<ExploreMedia />} />
<Route path="moderation/job-search" element={<JobTokenSearch />} />
<Route path="moderation/job/:jobToken" element={<JobInfo />} />
<Route
path="moderation/debug-logs-search"
element={<DebugLogsSearch />}
/>
<Route
path="moderation/debug-logs/:eventToken"
element={<DebugLogs />}
/>
</Route>
{/* 404 */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
);
}
function App() {
return (
<ThemeProvider defaultTheme="dark" attribute="class">
<BrowserRouter>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
</ThemeProvider>
);
}
export default App;
@@ -1,124 +0,0 @@
export interface ApiResponse<T, P = undefined> {
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<B, T>(
endpoint: string,
{
method,
query,
body,
}: {
method: string;
query?: Record<string, string | boolean | number | undefined>;
body?: B;
},
): Promise<T> {
const queryInString =
query &&
Object.entries(query).reduce(
(allOptions, [key, value]) => {
if (!value) {
return allOptions;
}
allOptions[key] = value.toString();
return allOptions;
},
{} as Record<string, string>,
);
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<T>({
endpoint,
query,
}: {
endpoint: string;
query?: Record<string, string | boolean | number | undefined>;
}): Promise<T> {
return this.fetch<null, T>(endpoint, { method: "GET", query });
}
protected post<B, T>({
endpoint,
query,
body,
}: {
endpoint: string;
query?: Record<string, string | boolean | number | undefined>;
body?: B;
}): Promise<T> {
return this.fetch<B, T>(endpoint, {
method: "POST",
query,
body,
});
}
protected put<B, T>({
endpoint,
query,
body,
}: {
endpoint: string;
query?: Record<string, string | boolean | number | undefined>;
body?: B;
}): Promise<T> {
return this.fetch<B, T>(endpoint, {
method: "PUT",
query,
body,
});
}
protected delete<B, T>({
endpoint,
query,
body,
}: {
endpoint: string;
query?: Record<string, string | boolean | number | undefined>;
body?: B;
}): Promise<T> {
return this.fetch<B, T>(endpoint, {
method: "DELETE",
query,
body,
});
}
}
@@ -1,305 +0,0 @@
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<ApiResponse<ExploreMediaFile[], CursorPagination>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/list`;
const queryParams: Record<string, string | number | boolean> = {};
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<ApiResponse<MediaInfo[], Pagination>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/media_files/list/user/${username}`;
// Convert to fully stringified dict
const queryWithStrings: Record<string, string | number | boolean> = {
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<ApiResponse<MediaFileDetail>> {
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<ApiResponse<PromptInfo>> {
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,
}));
}
}
@@ -1,798 +0,0 @@
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<ApiResponse<{ maybe_user?: ModeratorUserLookupResponse }>> {
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<ApiResponse<{ password_token: string }>> {
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<ApiResponse<void>> {
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<ApiResponse<void>> {
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<ApiResponse<{ wallets: Wallet[] }>> {
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<ApiResponse<{ maybe_wallet: WalletWithOwner | null }>> {
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<ApiResponse<void>> {
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<ApiResponse<{ entries: WalletLedgerEntryWithWallet[] }>> {
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<ApiResponse<{ maybe_entry: WalletLedgerEntry | null }>> {
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<ApiResponse<void>> {
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<ApiResponse<{ maybe_job: ModerationJob | null }>> {
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<ApiResponse<{ debug_logs: DebugLog[] }>> {
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<ApiResponse<{ jobs: UserJob[] }>> {
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<ApiResponse<UserFeatureFlagsResponse>> {
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<ApiResponse<void>> {
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<ApiResponse<void>> {
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,
}));
}
}
@@ -1,299 +0,0 @@
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<ApiResponse<AppStateResponse>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/app_state`;
return this.get<AppStateResponse>({ 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<ApiResponse<{ signedSession?: string }>> {
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<ApiResponse<null>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/logout`;
return this.post<null, { success: boolean; error_message?: string }>({
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<ApiResponse<{ signedSession?: string }>> {
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<string, string>;
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<ApiResponse<null>> {
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<ApiResponse<null>> {
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<ApiResponse<null>> {
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<ApiResponse<null>> {
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<ApiResponse<null>> {
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,
}));
}
}
@@ -1,614 +0,0 @@
[
{
"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"
}
]
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72.22 65.68">
<defs>
<style>
.cls-1 {
fill: #2d81ff;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<path class="cls-1" d="M69.38,45.46c-3.18-6.24-6.58-12.49-9.86-18.53-3.31-6.08-6.72-12.37-9.91-18.63l-.05-.09C46.44,3.07,41.24,0,35.66,0s-10.34,3-13.13,8.23L.97,48.96l-.03.07c-1.51,3.8-1.2,8.03.82,11.31,1.9,3.08,5.01,4.96,8.76,5.28.38.03.75.05,1.1.05,2.94,0,4.98-1.12,7.33-2.41,7.13-3.91,19.45-11.21,27.61-16.04,2.6-1.54,4.95-2.93,6.31-3.73l10.71,20.43c.2.39.54.7.95.86.46.19.98.2,1.43,0,1.39-.66,3.05-2.47,3.07-2.49,5.22-5.9,3.03-11.55.34-16.83ZM34.53,14.8c.31-.46.77-.99,1.32-.99.58,0,1.29.57,1.85,1.49,1.57,2.56,2.99,5.44,4.35,8.22,1.34,2.73,2.72,5.55,4.29,8.15l.02.05-29.6,16.99L34.53,14.8Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 885 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

@@ -1,216 +0,0 @@
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<string | null>(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 (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" overlayClassName="bg-black/70">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ActionIcon
className={cn(
"size-5",
willBan ? "text-destructive" : "text-emerald-400",
)}
/>
{actionLabel} User
</DialogTitle>
<DialogDescription>
{willBan ? (
<>
You are about to ban{" "}
<span className="font-semibold text-foreground/80">
{displayName}
</span>{" "}
(@{username}). They will lose access to their account.
</>
) : (
<>
You are about to unban{" "}
<span className="font-semibold text-foreground/80">
{displayName}
</span>{" "}
(@{username}). They will regain access to their account.
</>
)}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="mod-notes" className="text-sm font-medium">
Moderator notes
</Label>
<textarea
id="mod-notes"
rows={4}
placeholder="Reason for this action (required)"
value={modNotes}
onChange={(e) => setModNotes(e.target.value)}
className={cn(
"w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none resize-none",
"focus-visible:border-primary transition-colors",
"placeholder:text-muted-foreground dark:bg-input/30",
)}
/>
<p className="text-xs text-muted-foreground">
Recorded in the staff audit log.
</p>
</div>
{error && (
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
size="sm"
variant={willBan ? "destructive" : "default"}
disabled={!isValid || isSubmitting}
onClick={() => setConfirmOpen(true)}
>
<ActionIcon className="size-3.5" />
{actionLabel} User
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent overlayClassName="bg-black/70">
<AlertDialogHeader>
<AlertDialogTitle>Confirm {actionLabel}</AlertDialogTitle>
<AlertDialogDescription>
You are about to{" "}
<span className="font-semibold text-foreground">
{actionLabel.toLowerCase()}
</span>{" "}
<span className="font-semibold text-foreground">
{displayName}
</span>{" "}
(@{username}). This action is recorded in the staff audit log.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSubmitting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
variant={willBan ? "destructive" : "default"}
disabled={isSubmitting}
onClick={(e) => {
e.preventDefault();
handleConfirm();
}}
>
{isSubmitting ? (
<Spinner className="size-3.5" />
) : (
<ActionIcon className="size-3.5" />
)}
{isSubmitting ? "Saving..." : `Confirm ${actionLabel}`}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -1,207 +0,0 @@
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<string | null>(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 (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md" overlayClassName="bg-black/70">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconMail className="size-5 text-muted-foreground" />
Change Email Address
</DialogTitle>
<DialogDescription>
Set a new email address for{" "}
<span className="font-semibold text-foreground/80">
{displayName}
</span>{" "}
(@{username}).
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
{currentEmail && (
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground uppercase tracking-wider">
Current email
</Label>
<span className="text-sm font-mono break-all">
{currentEmail}
</span>
</div>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="new-email" className="text-sm font-medium">
New email address
</Label>
<Input
id="new-email"
type="email"
autoComplete="off"
placeholder="new.address@example.com"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && canSubmit) setConfirmOpen(true);
}}
/>
<p className="text-xs text-muted-foreground">
Recorded in the email-change history and the staff audit log.
</p>
</div>
{error && (
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
size="sm"
disabled={!canSubmit || isSubmitting}
onClick={() => setConfirmOpen(true)}
>
<IconMail className="size-3.5" />
Change Email
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent overlayClassName="bg-black/70">
<AlertDialogHeader>
<AlertDialogTitle>Confirm email change</AlertDialogTitle>
<AlertDialogDescription>
You are about to change the email address for{" "}
<span className="font-semibold text-foreground">
{displayName}
</span>{" "}
(@{username}) to{" "}
<span className="font-semibold text-foreground break-all">
{trimmedEmail}
</span>
. This action is recorded in the staff audit log.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSubmitting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={isSubmitting}
onClick={(e) => {
e.preventDefault();
handleConfirm();
}}
>
{isSubmitting ? (
<Spinner className="size-3.5" />
) : (
<IconMail className="size-3.5" />
)}
{isSubmitting ? "Saving..." : "Confirm Change"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -1,174 +0,0 @@
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 (
<>
<Dialog
open={open}
onOpenChange={(o) => {
onOpenChange(o);
if (!o) reset();
}}
>
<DialogContent className="sm:max-w-md" overlayClassName="bg-black/70">
<DialogHeader>
<DialogTitle>Give Banked Credits</DialogTitle>
<DialogDescription>
Add purchased (banked) credits to{" "}
<span className="font-semibold text-foreground/80">
{displayName}
</span>
's wallet.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<Input
type="number"
min={1}
placeholder="Amount of credits"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
100 credits = $1.00 (1 credit = 1&cent;)
{dollarValue && (
<span className="ml-1 text-foreground font-medium">
&middot; {parsedAmount.toLocaleString()} credits = $
{dollarValue} USD
</span>
)}
</p>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
size="sm"
disabled={!isValid}
onClick={() => setConfirmOpen(true)}
>
<IconCoin className="size-3.5" />
Continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent overlayClassName="bg-black/70">
<AlertDialogHeader>
<AlertDialogTitle>Confirm Credit Addition</AlertDialogTitle>
<AlertDialogDescription>
You are about to add{" "}
<span className="font-semibold text-foreground">
{isValid ? parsedAmount.toLocaleString() : 0}
</span>{" "}
banked credits{" "}
{dollarValue && (
<>
(
<span className="font-semibold text-foreground">
${dollarValue}
</span>
){" "}
</>
)}
to{" "}
<span className="font-semibold text-foreground">
{displayName}
</span>
. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSubmitting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={isSubmitting}
onClick={(e) => {
e.preventDefault();
handleConfirm();
}}
>
{isSubmitting ? (
<Spinner className="size-3.5" />
) : (
<IconCoin className="size-3.5" />
)}
{isSubmitting ? "Adding..." : "Confirm"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -1,163 +0,0 @@
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<string | null>(
null,
);
const [error, setError] = useState<string | null>(null);
const [copiedField, setCopiedField] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" overlayClassName="bg-black/70">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconKey className="size-5" />
Impersonate User
</DialogTitle>
<DialogDescription>
Use the credentials below to log in as{" "}
<span className="font-semibold text-foreground/80">
{displayName}
</span>
.
</DialogDescription>
</DialogHeader>
{isLoading && (
<div className="flex items-center justify-center py-8">
<Spinner className="size-6 opacity-60" />
</div>
)}
{error && !isLoading && (
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{impersonationToken && !isLoading && (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Username
</span>
<Button
variant="outline"
size="sm"
className="h-9 justify-start text-xs font-mono px-2 overflow-hidden"
onClick={() => copy(username, "username")}
>
{copiedField === "username" ? (
<IconCheck className="size-3.5 shrink-0 text-green-500" />
) : (
<IconCopy className="size-3.5 shrink-0" />
)}
<span className="truncate">{username}</span>
</Button>
</div>
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Password (Impersonation Token)
</span>
<Button
variant="outline"
size="sm"
className="h-9 justify-start text-xs font-mono px-2 overflow-hidden"
onClick={() => copy(impersonationToken, "token")}
>
{copiedField === "token" ? (
<IconCheck className="size-3.5 shrink-0 text-green-500" />
) : (
<IconCopy className="size-3.5 shrink-0" />
)}
<span className="truncate">{impersonationToken}</span>
</Button>
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,298 +0,0 @@
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<string | null>(null);
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopiedToken(text);
setTimeout(() => setCopiedToken(null), 2000);
});
};
if (isLoading) {
return (
<Table containerClassName="rounded-xl border bg-card shadow-sm overflow-hidden">
<TableHeader>
<TableRow className="hover:bg-transparent bg-muted/30">
<TableHead className="text-xs">Token</TableHead>
<TableHead className="text-xs">Status</TableHead>
<TableHead className="text-xs text-right">Credits</TableHead>
<TableHead className="text-xs">Failure Reason</TableHead>
<TableHead className="text-xs">Refund</TableHead>
<TableHead className="text-xs">Result</TableHead>
<TableHead className="text-xs">3rd Party</TableHead>
<TableHead className="text-xs">3rd Party ID</TableHead>
<TableHead className="text-xs">Created</TableHead>
<TableHead className="text-xs">Updated</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: skeletonRows }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-20 rounded-full" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-4 w-14 ml-auto" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-32" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
if (jobs.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 bg-muted/20 border border-dashed rounded-xl">
<IconBriefcase className="size-10 text-muted-foreground mb-4 opacity-20" />
<p className="text-muted-foreground">No jobs found for this user.</p>
</div>
);
}
return (
<Table containerClassName="rounded-xl border bg-card shadow-sm min-h-[200px]" containerStyle={{ maxHeight: maxHeight ?? "60vh" }}>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow className="hover:bg-transparent bg-muted/30">
<TableHead className="text-xs">Token</TableHead>
<TableHead className="text-xs">Status</TableHead>
<TableHead className="text-xs text-right">Credits</TableHead>
<TableHead className="text-xs">Failure Reason</TableHead>
<TableHead className="text-xs">Refund</TableHead>
<TableHead className="text-xs">Result</TableHead>
<TableHead className="text-xs">3rd Party</TableHead>
<TableHead className="text-xs">3rd Party ID</TableHead>
<TableHead className="text-xs">Created</TableHead>
<TableHead className="text-xs">Updated</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map((job) => {
const badge = getJobStatusBadgeProps(job.job_status);
return (
<TableRow key={job.job_token} className="group">
<TableCell>
<div className="flex items-center gap-0.5">
<Link
to={`/moderation/job/${encodeURIComponent(job.job_token)}`}
className="text-[10px] font-mono text-muted-foreground hover:text-foreground hover:underline truncate max-w-[120px]"
title="Open job info"
>
{job.job_token}
</Link>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground opacity-60 group-hover:opacity-100 transition-opacity"
onClick={() => copyToClipboard(job.job_token)}
title="Copy job token"
>
{copiedToken === job.job_token ? (
<IconCheck className="size-3 text-emerald-400" />
) : (
<IconCopy className="size-3" />
)}
</Button>
</div>
</TableCell>
<TableCell>
<Badge variant={badge.variant} className={badge.className}>
{badge.label}
</Badge>
</TableCell>
<TableCell className="text-right font-mono text-sm tabular-nums">
{job.credits_delta != null ? (
<span
className={
job.credits_delta > 0
? "text-emerald-400"
: job.credits_delta < 0
? "text-destructive"
: "text-muted-foreground"
}
>
{job.credits_delta > 0 ? "+" : ""}
{job.credits_delta.toLocaleString()}
</span>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
)}
</TableCell>
<TableCell className="text-xs max-w-xs">
{job.job_failure_reason ? (
<span className="text-destructive line-clamp-2">
{job.job_failure_reason}
</span>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
)}
</TableCell>
<TableCell className="text-xs">
{job.maybe_linked_refund_ledger_token ? (
<Badge
variant="outline"
className="text-muted-foreground text-[10px]"
>
Refunded
</Badge>
) : badge.isFailed &&
job.credits_delta != null &&
job.credits_delta < 0 ? (
<span className="text-[10px] text-amber-400 font-medium uppercase tracking-wider">
Needs refund
</span>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
)}
</TableCell>
<TableCell className="text-xs">
{job.on_success_result_media_token ? (
<a
href={`https://getartcraft.com/media/${job.on_success_result_media_token}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-foreground/80 hover:underline"
>
<IconExternalLink className="size-3" />
View
</a>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
)}
</TableCell>
<TableCell className="text-xs">
{job.maybe_external_third_party ? (
<span className="font-medium">{job.maybe_external_third_party}</span>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
)}
</TableCell>
<TableCell>
{job.maybe_external_third_party_id ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[10px] font-mono text-muted-foreground hover:text-foreground opacity-60 group-hover:opacity-100 transition-opacity"
onClick={() => copyToClipboard(job.maybe_external_third_party_id!)}
title="Copy 3rd party ID"
>
{copiedToken === job.maybe_external_third_party_id ? (
<IconCheck className="size-3 text-emerald-400" />
) : (
<IconCopy className="size-3" />
)}
<span className="truncate max-w-[120px] inline-block align-middle">{job.maybe_external_third_party_id}</span>
</Button>
) : (
<span className="text-muted-foreground/30 text-xs">&mdash;</span>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{new Date(job.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}{" "}
<span className="text-muted-foreground/50">{new Date(job.created_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}</span>
</TableCell>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{new Date(job.updated_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}{" "}
<span className="text-muted-foreground/50">{new Date(job.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}</span>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}
@@ -1,214 +0,0 @@
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<string, typeof IconPhoto> = {
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 (
<>
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={(e) => {
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 */}
<div className="absolute top-1.5 left-1.5 z-10 flex items-center gap-1 flex-wrap">
<span className="px-2 py-1 rounded-md bg-black/40 text-[10px] font-medium text-white backdrop-blur-sm uppercase">
{mediaClass === "dimensional" ? "3D" : mediaClass || "Unknown"}
</span>
{isUserUpload && (
<span
className="flex items-center gap-1 px-1.5 py-1 rounded-md bg-black/40 text-[10px] font-medium text-white backdrop-blur-sm uppercase"
title="User upload"
>
<IconCloudUpload className="size-3" />
Uploaded
</span>
)}
</div>
{/* Top-right: created date/time + (optional) duration */}
<div className="absolute top-1.5 right-1.5 z-10 flex flex-col items-end gap-1">
{createdAt && (
<span
className="px-1.5 py-0.5 rounded-md bg-black/50 text-[10px] font-medium text-white backdrop-blur-sm tabular-nums whitespace-nowrap"
title={createdAt.full}
>
{createdAt.short}
</span>
)}
{durationMs != null && durationMs > 0 && (
<span className="px-1.5 py-0.5 rounded-md bg-black/50 text-[10px] font-medium text-white backdrop-blur-sm tabular-nums">
{formatDuration(durationMs)}
</span>
)}
</div>
{/* Thumbnail or fallback */}
{imgSrc ? (
<>
{!imgLoaded && (
<div className="absolute inset-0 flex items-center justify-center">
<Spinner className="size-5 opacity-40" />
</div>
)}
<img
src={imgSrc}
alt={item.maybe_title || item.token}
className={`w-full h-full transition-transform group-hover:scale-105 object-contain ${!imgLoaded ? "opacity-0" : "opacity-100"}`}
loading="lazy"
onLoad={() => setImgLoaded(true)}
onError={handleError}
/>
</>
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-muted-foreground">
<FallbackIcon className="size-14 opacity-30" />
</div>
)}
{/* Hover overlay — title / transcript (sits above the always-visible meta strip) */}
{hoverLabel && (
<div className="absolute inset-x-0 top-0 bottom-14 bg-linear-to-b from-black/70 via-black/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-start p-3 pointer-events-none z-10">
<div className="text-xs text-white/95 line-clamp-4">
{hoverLabel}
</div>
</div>
)}
{/* Always-visible metadata overlay (bottom): creator + token */}
<div className="absolute inset-x-0 bottom-0 z-20 px-2.5 pb-2 pt-8 bg-linear-to-t from-black/85 via-black/55 to-transparent flex flex-col gap-0.5 min-w-0">
{hasMetaFooter && (
<Link
to={`/user/profile/${creator!.username}`}
onClick={(e) => e.stopPropagation()}
className="text-sm font-medium text-white truncate hover:underline"
title={creator!.display_name || creator!.username}
>
@{creator!.username}
</Link>
)}
<span
className="font-mono text-[10px] text-white/55 truncate"
title={item.token}
>
{item.token}
</span>
</div>
</div>
{!onSelect && (
<MediaDetailModal
item={item}
creator={creatorOverride ?? undefined}
open={modalOpen}
onOpenChange={setModalOpen}
/>
)}
</>
);
}
@@ -1,457 +0,0 @@
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<string, string> = {
image: "Image",
video: "Video",
dimensional: "3D",
};
const classIcons: Record<string, typeof IconPhoto> = {
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<string, string> = {
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<PromptInfo | null>(null);
const [promptFailed, setPromptFailed] = useState(false);
const [promptExpanded, setPromptExpanded] = useState(false);
const [isPromptOverflowing, setIsPromptOverflowing] = useState(false);
const promptRef = useRef<HTMLDivElement>(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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="max-w-[calc(100%-2rem)] sm:max-w-7xl p-0 gap-0 overflow-hidden"
overlayClassName="bg-black/80"
>
<DialogTitle className="sr-only">{title}</DialogTitle>
<div className="flex flex-col md:flex-row h-[75vh]">
{/* Left: Media preview */}
<div className="relative flex-1 min-h-[300px] md:min-h-[400px] bg-black flex items-center justify-center overflow-hidden">
{isVideo && fullUrl ? (
<video
src={fullUrl}
controls
autoPlay
className="w-full h-full object-contain max-h-[85vh]"
poster={videoPoster}
/>
) : thumbnailUrl ? (
<>
{!imgLoaded && (
<div className="absolute inset-0 flex items-center justify-center">
<Spinner className="size-8 opacity-40" />
</div>
)}
<img
src={thumbnailUrl}
alt={title}
className={`w-full h-full object-contain max-h-[85vh] transition-opacity ${!imgLoaded ? "opacity-0" : "opacity-100"}`}
onLoad={() => setImgLoaded(true)}
/>
</>
) : (
<FallbackIcon className="size-24 opacity-20 text-white" />
)}
</div>
{/* Right: Details panel */}
<div className="w-full md:w-80 flex flex-col border-l border-border bg-background max-h-[85vh]">
{/* Creator header - sticky top */}
{creator && (
<button
type="button"
onClick={handleCreatorClick}
className="shrink-0 flex items-center gap-2.5 px-4 py-3 border-b border-border hover:bg-muted/50 transition-colors text-left cursor-pointer"
>
<Avatar>
<AvatarImage
src={
creator.gravatar_hash
? `https://gravatar.com/avatar/${creator.gravatar_hash}?s=64&d=404`
: undefined
}
/>
<AvatarFallback className="p-0">
<img
src={getIdenticonUrl(creator.username)}
alt={creator.username}
className="h-full w-full"
/>
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="text-sm font-medium truncate">
{creator.display_name || creator.username}
</div>
<div className="text-xs text-muted-foreground">Author</div>
</div>
</button>
)}
{/* Scrollable details */}
<div className="flex-1 overflow-y-auto min-h-0">
{/* Prompt section */}
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wide">
<IconPencil className="size-3.5" />
Prompt
</div>
{promptText && (
<button
type="button"
onClick={handleCopyPrompt}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{copied ? (
<IconCheck className="size-3.5 text-green-500" />
) : (
<IconCopy className="size-3.5" />
)}
</button>
)}
</div>
<div className="relative">
<div
ref={promptRef}
className={`text-sm bg-muted/50 p-2.5 leading-relaxed ${promptText && (isPromptOverflowing || promptExpanded) ? "rounded-t-lg" : "rounded-lg"} ${!promptExpanded ? "max-h-18 overflow-hidden" : ""}`}
>
{promptText ||
(promptFailed ? "No prompt available" : "Loading...")}
</div>
{promptText && (isPromptOverflowing || promptExpanded) && (
<button
type="button"
onClick={() => 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"}
<IconChevronDown
className={`size-3.5 transition-transform ${promptExpanded ? "rotate-180" : ""}`}
/>
</button>
)}
</div>
</div>
{/* Information section */}
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
<IconInfoCircle className="size-3.5" />
Information
</div>
<div className="bg-muted/50 rounded-lg divide-y divide-border">
<div className="flex items-center justify-between px-3 py-2.5">
<span className="text-sm text-muted-foreground">Feature</span>
<span className="text-sm font-medium">
{generationMode ||
(classLabels[item.media_class]
? `Create ${classLabels[item.media_class]}`
: item.media_class)}
</span>
</div>
<div className="flex items-center justify-between px-3 py-2.5">
<span className="text-sm text-muted-foreground">Model</span>
<span className="text-sm font-medium">
{modelDisplay ?? (
<span className="text-muted-foreground">
{promptFailed ? "Unknown" : "Loading..."}
</span>
)}
</span>
</div>
<div className="flex items-center justify-between px-3 py-2.5">
<span className="text-sm text-muted-foreground">Type</span>
<span className="text-sm font-medium">{item.media_type}</span>
</div>
</div>
</div>
{/* Additional section */}
<div className="px-4 py-3">
<button
type="button"
onClick={() => setAdditionalOpen((v) => !v)}
className="flex items-center justify-between w-full"
>
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wide">
<IconSettings className="size-3.5" />
Additional
</div>
<IconChevronDown
className={`size-4 text-muted-foreground transition-transform ${additionalOpen ? "rotate-180" : ""}`}
/>
</button>
{additionalOpen && (
<div className="mt-3 bg-muted/50 rounded-lg divide-y divide-border">
{resolution && (
<div className="flex items-center justify-between px-3 py-2.5">
<span className="text-sm text-muted-foreground">Resolution</span>
<span className="text-sm font-medium">{resolution}</span>
</div>
)}
{aspectRatio && (
<div className="flex items-center justify-between px-3 py-2.5">
<span className="text-sm text-muted-foreground">Aspect Ratio</span>
<span className="text-sm font-medium">{aspectRatio}</span>
</div>
)}
{promptInfo?.maybe_generation_provider && (
<div className="flex items-center justify-between px-3 py-2.5">
<span className="text-sm text-muted-foreground">Provider</span>
<span className="text-sm font-medium">
{formatModelType(promptInfo.maybe_generation_provider)}
</span>
</div>
)}
{createdDate && (
<div className="flex items-center justify-between px-3 py-2.5">
<span className="text-sm text-muted-foreground">Created</span>
<span className="text-sm font-medium">{createdDate}</span>
</div>
)}
{!resolution && !aspectRatio && !promptInfo?.maybe_generation_provider && !createdDate && (
<div className="px-3 py-2.5 text-sm text-muted-foreground">No additional details</div>
)}
</div>
)}
</div>
</div>
{/* Actions - sticky bottom */}
<div className="shrink-0 px-4 py-3 border-t border-border">
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={handleDownload}
>
<IconDownload className="size-4" />
Download
</Button>
<Button variant="outline" className="flex-1" asChild>
<a
href={externalUrl}
target="_blank"
rel="noopener noreferrer"
>
<IconExternalLink className="size-4" />
View on Artcraft
</a>
</Button>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,22 +0,0 @@
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 (
<span
title="Raw backend value"
className={cn(
"text-[10px] font-mono text-muted-foreground/60 leading-none",
className,
)}
>
{value}
</span>
);
}
@@ -1,78 +0,0 @@
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 (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<Icon className="size-6 text-muted-foreground" />
{title}
</h1>
<p className="text-muted-foreground">{description}</p>
</div>
<Card className="max-w-2xl">
<CardHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
<label
htmlFor="token"
className="text-xs font-medium text-muted-foreground uppercase tracking-wider"
>
{inputLabel}
</label>
<div className="flex gap-2">
<Input
id="token"
type="text"
placeholder={placeholder}
value={token}
onChange={(e) => setToken(e.target.value)}
className="flex-1 font-mono text-sm"
autoFocus
/>
<Button type="submit" disabled={!token.trim()}>
<IconSearch className="size-4" />
{buttonLabel}
<IconArrowRight className="size-4" />
</Button>
</div>
</form>
</CardHeader>
</Card>
</div>
);
}
@@ -1,378 +0,0 @@
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<FeatureFlagDescriptor[]>(
[],
);
const [initialUserFlags, setInitialUserFlags] = useState<string[]>([]);
const [selectedFlags, setSelectedFlags] = useState<Set<string>>(new Set());
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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<string, FeatureFlagDescriptor>();
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 (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-lg flex flex-col"
overlayClassName="bg-black/70"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconFlag className="size-5 text-muted-foreground" />
Feature Flags
</DialogTitle>
<DialogDescription>
Manage feature flags for{" "}
<span className="font-semibold text-foreground/80">
{displayName}
</span>{" "}
(@{username}).
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="relative">
<IconSearch className="size-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none" />
<Input
type="text"
placeholder="Filter flags..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
disabled={isLoading}
className="pl-9"
/>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground px-1">
<span>
{isLoading
? "Loading..."
: `${selectedCount} of ${allFlagRows.length} enabled`}
</span>
{!isLoading && initialUserFlags.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-destructive hover:text-destructive"
onClick={() => setClearConfirmOpen(true)}
disabled={isSubmitting}
>
<IconTrash className="size-3" />
Clear all
</Button>
)}
</div>
<div className="max-h-[50vh] overflow-y-auto rounded-md border bg-muted/20">
{isLoading ? (
<div className="flex items-center justify-center py-10">
<Spinner className="size-5 opacity-50" />
</div>
) : filteredFlags.length === 0 ? (
<p className="text-sm text-muted-foreground italic px-3 py-6 text-center">
{allFlagRows.length === 0
? "No feature flags available."
: "No flags match your filter."}
</p>
) : (
<ul className="divide-y divide-border/50">
{filteredFlags.map((flag) => {
const checked = selectedFlags.has(flag.key);
const isUnknown = !availableByKey.has(flag.key);
const Icon = getFeatureFlagIcon(flag);
return (
<li key={flag.key}>
<label className="flex items-start gap-3 px-3 py-2.5 cursor-pointer hover:bg-muted/40 transition-colors">
<Checkbox
className="mt-0.5"
checked={checked}
onCheckedChange={(v) =>
toggleFlag(flag.key, v === true)
}
/>
<Icon className="size-4 text-muted-foreground shrink-0 mt-0.5" />
<div className="flex flex-col flex-1 min-w-0 gap-0.5">
<div className="flex items-center gap-2 flex-wrap">
{!isUnknown && flag.full_name && (
<span className="text-sm font-medium">
{flag.full_name}
</span>
)}
<span className="text-xs font-mono text-muted-foreground break-all">
{flag.key}
</span>
{isUnknown && (
<Badge
variant="outline"
className="text-[10px] py-0 px-1.5 opacity-70"
>
Unknown
</Badge>
)}
</div>
{flag.description && (
<p className="text-xs text-muted-foreground">
{flag.description}
</p>
)}
</div>
</label>
</li>
);
})}
</ul>
)}
</div>
{error && (
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
size="sm"
disabled={!hasChanges || isSubmitting || isLoading}
onClick={handleSave}
>
{isSubmitting ? (
<Spinner className="size-3.5" />
) : (
<IconDeviceFloppy className="size-3.5" />
)}
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={clearConfirmOpen} onOpenChange={setClearConfirmOpen}>
<AlertDialogContent overlayClassName="bg-black/70">
<AlertDialogHeader>
<AlertDialogTitle>Clear all feature flags?</AlertDialogTitle>
<AlertDialogDescription>
This will remove every feature flag currently enabled for{" "}
<span className="font-semibold text-foreground">
{displayName}
</span>{" "}
(@{username}). This action is recorded in the staff audit log.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSubmitting}>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={isSubmitting}
onClick={(e) => {
e.preventDefault();
handleClearAll();
}}
>
{isSubmitting ? (
<Spinner className="size-3.5" />
) : (
<IconTrash className="size-3.5" />
)}
{isSubmitting ? "Clearing..." : "Clear all flags"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -1,157 +0,0 @@
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<string | null>(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 (
<div className="rounded-xl border bg-card text-card-foreground shadow-sm flex flex-col p-5 max-w-2xl">
<h4 className="text-sm font-semibold mb-4 text-foreground/80 uppercase tracking-wider pb-3 border-b border-border/50 flex items-center gap-2 h-[45px]">
<IconUser className="size-4" />
{title}
</h4>
<form onSubmit={handleSubmit} className="flex items-center gap-2 mb-3">
<Input
type="text"
placeholder="Username, email, or token..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1"
/>
<Button type="submit" disabled={isSearching || !searchQuery.trim()}>
<IconSearch className="size-4" />
{isSearching ? "Searching..." : "Find"}
</Button>
</form>
{isSearching && (
<div className="rounded-lg border bg-muted/20 p-3 flex items-center gap-3">
<Skeleton className="size-12 rounded-md" />
<div className="flex flex-col gap-1.5 flex-1">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-20" />
</div>
<Skeleton className="h-8 w-28 rounded-md" />
</div>
)}
{searchError && !isSearching && (
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>
{searchError.charAt(0).toUpperCase() + searchError.slice(1)}
</AlertDescription>
</Alert>
)}
{user && !isSearching && (
<div className="rounded-lg border bg-muted/20 p-3 flex items-center gap-3">
<Avatar className="size-12 rounded-md shrink-0">
<AvatarImage
src={gravatarUrl(user.email_gravatar_hash)}
draggable={false}
/>
<AvatarFallback className="rounded-md p-0">
<img
src={getIdenticonUrl(user.username)}
alt={user.username}
className="size-full"
draggable={false}
/>
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold leading-tight font-outfit truncate">
{user.display_name || user.username}
</span>
{user.is_banned && (
<Badge variant="destructive" className="text-[10px]">
Banned
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground truncate">
@{user.username}
</span>
{email && (
<span className="text-xs text-muted-foreground/80 font-mono truncate">
{email}
</span>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
{actions?.(user)}
<Button variant="ghost" size="icon" className="size-8" asChild>
<Link to={`/user/profile/${user.username}`} title="View profile">
<IconArrowRight className="size-4" />
</Link>
</Button>
</div>
</div>
)}
</div>
);
}
@@ -1,239 +0,0 @@
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<typeof Sidebar>) {
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 (
<>
<Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
className="data-[slot=sidebar-menu-button]:p-1.5! text-foreground"
>
<Link to="/">
<img
src={artcraftIcon}
className="size-5"
alt="Artcraft Icon"
/>
<span className="text-[1.06rem] font-semibold font-outfit">
ArtCraft Admin
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
{/* <NavDocuments items={data.documents} /> */}
<NavSecondary items={navSecondaryConfig} className="mt-auto" />
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter>
</Sidebar>
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
</>
);
}
@@ -1,291 +0,0 @@
"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 (
<Card className="@container/card">
<CardHeader>
<CardTitle>Total Visitors</CardTitle>
<CardDescription>
<span className="hidden @[540px]/card:block">
Total for the last 3 months
</span>
<span className="@[540px]/card:hidden">Last 3 months</span>
</CardDescription>
<CardAction>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:px-4! @[767px]/card:flex"
>
<ToggleGroupItem value="90d">Last 3 months</ToggleGroupItem>
<ToggleGroupItem value="30d">Last 30 days</ToggleGroupItem>
<ToggleGroupItem value="7d">Last 7 days</ToggleGroupItem>
</ToggleGroup>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
size="sm"
aria-label="Select a value"
>
<SelectValue placeholder="Last 3 months" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="90d" className="rounded-lg">
Last 3 months
</SelectItem>
<SelectItem value="30d" className="rounded-lg">
Last 30 days
</SelectItem>
<SelectItem value="7d" className="rounded-lg">
Last 7 days
</SelectItem>
</SelectContent>
</Select>
</CardAction>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={filteredData}>
<defs>
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-desktop)"
stopOpacity={1.0}
/>
<stop
offset="95%"
stopColor="var(--color-desktop)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-mobile)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-mobile)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value)
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(value) => {
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})
}}
indicator="dot"
/>
}
/>
<Area
dataKey="mobile"
type="natural"
fill="url(#fillMobile)"
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="url(#fillDesktop)"
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)
}
@@ -1,805 +0,0 @@
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 (
<Button
{...attributes}
{...listeners}
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:bg-transparent"
>
<IconGripVertical className="size-3 text-muted-foreground" />
<span className="sr-only">Drag to reorder</span>
</Button>
)
}
const columns: ColumnDef<z.infer<typeof schema>>[] = [
{
id: "drag",
header: () => null,
cell: ({ row }) => <DragHandle id={row.original.id} />,
},
{
id: "select",
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "header",
header: "Header",
cell: ({ row }) => {
return <TableCellViewer item={row.original} />
},
enableHiding: false,
},
{
accessorKey: "type",
header: "Section Type",
cell: ({ row }) => (
<div className="w-32">
<Badge variant="outline" className="px-1.5 text-muted-foreground">
{row.original.type}
</Badge>
</div>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => (
<Badge variant="outline" className="px-1.5 text-muted-foreground">
{row.original.status === "Done" ? (
<IconCircleCheckFilled className="fill-green-500 dark:fill-green-400" />
) : (
<IconLoader />
)}
{row.original.status}
</Badge>
),
},
{
accessorKey: "target",
header: () => <div className="w-full text-right">Target</div>,
cell: ({ row }) => (
<form
onSubmit={(e) => {
e.preventDefault()
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
loading: `Saving ${row.original.header}`,
success: "Done",
error: "Error",
})
}}
>
<Label htmlFor={`${row.original.id}-target`} className="sr-only">
Target
</Label>
<Input
className="h-8 w-16 border-transparent bg-transparent text-right shadow-none hover:bg-input/30 focus-visible:border focus-visible:bg-background dark:bg-transparent dark:hover:bg-input/30 dark:focus-visible:bg-input/30"
defaultValue={row.original.target}
id={`${row.original.id}-target`}
/>
</form>
),
},
{
accessorKey: "limit",
header: () => <div className="w-full text-right">Limit</div>,
cell: ({ row }) => (
<form
onSubmit={(e) => {
e.preventDefault()
toast.promise(new Promise((resolve) => setTimeout(resolve, 1000)), {
loading: `Saving ${row.original.header}`,
success: "Done",
error: "Error",
})
}}
>
<Label htmlFor={`${row.original.id}-limit`} className="sr-only">
Limit
</Label>
<Input
className="h-8 w-16 border-transparent bg-transparent text-right shadow-none hover:bg-input/30 focus-visible:border focus-visible:bg-background dark:bg-transparent dark:hover:bg-input/30 dark:focus-visible:bg-input/30"
defaultValue={row.original.limit}
id={`${row.original.id}-limit`}
/>
</form>
),
},
{
accessorKey: "reviewer",
header: "Reviewer",
cell: ({ row }) => {
const isAssigned = row.original.reviewer !== "Assign reviewer"
if (isAssigned) {
return row.original.reviewer
}
return (
<>
<Label htmlFor={`${row.original.id}-reviewer`} className="sr-only">
Reviewer
</Label>
<Select>
<SelectTrigger
className="w-38 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate"
size="sm"
id={`${row.original.id}-reviewer`}
>
<SelectValue placeholder="Assign reviewer" />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
<SelectItem value="Jamik Tashpulatov">
Jamik Tashpulatov
</SelectItem>
</SelectContent>
</Select>
</>
)
},
},
{
id: "actions",
cell: () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex size-8 text-muted-foreground data-[state=open]:bg-muted"
size="icon"
>
<IconDotsVertical />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Make a copy</DropdownMenuItem>
<DropdownMenuItem>Favorite</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
]
function DraggableRow({ row }: { row: Row<z.infer<typeof schema>> }) {
const { transform, transition, setNodeRef, isDragging } = useSortable({
id: row.original.id,
})
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
data-dragging={isDragging}
ref={setNodeRef}
className="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
style={{
transform: CSS.Transform.toString(transform),
transition: transition,
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
export function DataTable({
data: initialData,
}: {
data: z.infer<typeof schema>[]
}) {
const [data, setData] = React.useState(() => initialData)
const [rowSelection, setRowSelection] = React.useState({})
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({})
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
)
const [sorting, setSorting] = React.useState<SortingState>([])
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<UniqueIdentifier[]>(
() => 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 (
<Tabs
defaultValue="outline"
className="w-full flex-col justify-start gap-6"
>
<div className="flex items-center justify-between px-4 lg:px-6">
<Label htmlFor="view-selector" className="sr-only">
View
</Label>
<Select defaultValue="outline">
<SelectTrigger
className="flex w-fit @4xl/main:hidden"
size="sm"
id="view-selector"
>
<SelectValue placeholder="Select a view" />
</SelectTrigger>
<SelectContent>
<SelectItem value="outline">Outline</SelectItem>
<SelectItem value="past-performance">Past Performance</SelectItem>
<SelectItem value="key-personnel">Key Personnel</SelectItem>
<SelectItem value="focus-documents">Focus Documents</SelectItem>
</SelectContent>
</Select>
<TabsList className="hidden **:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:bg-muted-foreground/30 **:data-[slot=badge]:px-1 @4xl/main:flex">
<TabsTrigger value="outline">Outline</TabsTrigger>
<TabsTrigger value="past-performance">
Past Performance <Badge variant="secondary">3</Badge>
</TabsTrigger>
<TabsTrigger value="key-personnel">
Key Personnel <Badge variant="secondary">2</Badge>
</TabsTrigger>
<TabsTrigger value="focus-documents">Focus Documents</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconLayoutColumns />
<span className="hidden lg:inline">Customize Columns</span>
<span className="lg:hidden">Columns</span>
<IconChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" &&
column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm">
<IconPlus />
<span className="hidden lg:inline">Add Section</span>
</Button>
</div>
</div>
<TabsContent
value="outline"
className="relative flex flex-col gap-4 overflow-auto px-4 lg:px-6"
>
<div className="overflow-hidden rounded-lg border">
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
sensors={sensors}
id={sortableId}
>
<Table>
<TableHeader className="sticky top-0 z-10 bg-muted">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody className="**:data-[slot=table-cell]:first:w-8">
{table.getRowModel().rows?.length ? (
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row) => (
<DraggableRow key={row.id} row={row} />
))}
</SortableContext>
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DndContext>
</div>
<div className="flex items-center justify-between px-4">
<div className="hidden flex-1 text-sm text-muted-foreground lg:flex">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
Rows per page
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
<SelectValue
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<IconChevronsLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<IconChevronLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<IconChevronRight />
</Button>
<Button
variant="outline"
className="hidden size-8 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<IconChevronsRight />
</Button>
</div>
</div>
</div>
</TabsContent>
<TabsContent
value="past-performance"
className="flex flex-col px-4 lg:px-6"
>
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
<TabsContent value="key-personnel" className="flex flex-col px-4 lg:px-6">
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
<TabsContent
value="focus-documents"
className="flex flex-col px-4 lg:px-6"
>
<div className="aspect-video w-full flex-1 rounded-lg border border-dashed"></div>
</TabsContent>
</Tabs>
)
}
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<typeof schema> }) {
const isMobile = useIsMobile()
return (
<Drawer direction={isMobile ? "bottom" : "right"}>
<DrawerTrigger asChild>
<Button variant="link" className="w-fit px-0 text-left text-foreground">
{item.header}
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="gap-1">
<DrawerTitle>{item.header}</DrawerTitle>
<DrawerDescription>
Showing total visitors for the last 6 months
</DrawerDescription>
</DrawerHeader>
<div className="flex flex-col gap-4 overflow-y-auto px-4 text-sm">
{!isMobile && (
<>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={chartData}
margin={{
left: 0,
right: 10,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => value.slice(0, 3)}
hide
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
<Area
dataKey="mobile"
type="natural"
fill="var(--color-mobile)"
fillOpacity={0.6}
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="var(--color-desktop)"
fillOpacity={0.4}
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
<Separator />
<div className="grid gap-2">
<div className="flex gap-2 leading-none font-medium">
Trending up by 5.2% this month{" "}
<IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">
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.
</div>
</div>
<Separator />
</>
)}
<form className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="header">Header</Label>
<Input id="header" defaultValue={item.header} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="type">Type</Label>
<Select defaultValue={item.type}>
<SelectTrigger id="type" className="w-full">
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Table of Contents">
Table of Contents
</SelectItem>
<SelectItem value="Executive Summary">
Executive Summary
</SelectItem>
<SelectItem value="Technical Approach">
Technical Approach
</SelectItem>
<SelectItem value="Design">Design</SelectItem>
<SelectItem value="Capabilities">Capabilities</SelectItem>
<SelectItem value="Focus Documents">
Focus Documents
</SelectItem>
<SelectItem value="Narrative">Narrative</SelectItem>
<SelectItem value="Cover Page">Cover Page</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="status">Status</Label>
<Select defaultValue={item.status}>
<SelectTrigger id="status" className="w-full">
<SelectValue placeholder="Select a status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Done">Done</SelectItem>
<SelectItem value="In Progress">In Progress</SelectItem>
<SelectItem value="Not Started">Not Started</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-3">
<Label htmlFor="target">Target</Label>
<Input id="target" defaultValue={item.target} />
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="limit">Limit</Label>
<Input id="limit" defaultValue={item.limit} />
</div>
</div>
<div className="flex flex-col gap-3">
<Label htmlFor="reviewer">Reviewer</Label>
<Select defaultValue={item.reviewer}>
<SelectTrigger id="reviewer" className="w-full">
<SelectValue placeholder="Select a reviewer" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Eddie Lake">Eddie Lake</SelectItem>
<SelectItem value="Jamik Tashpulatov">
Jamik Tashpulatov
</SelectItem>
<SelectItem value="Emily Whalen">Emily Whalen</SelectItem>
</SelectContent>
</Select>
</div>
</form>
</div>
<DrawerFooter>
<Button>Submit</Button>
<DrawerClose asChild>
<Button variant="outline">Done</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
@@ -1,92 +0,0 @@
"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 (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Documents</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="rounded-sm data-[state=open]:bg-accent"
>
<IconDots />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-24 rounded-lg"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem>
<IconFolder />
<span>Open</span>
</DropdownMenuItem>
<DropdownMenuItem>
<IconShare3 />
<span>Share</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<IconTrash />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<IconDots className="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
}
@@ -1,54 +0,0 @@
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 (
<SidebarGroup>
<SidebarGroupContent className="flex flex-col gap-2">
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
isActive={pathname === item.url}
onClick={handleClick}
asChild
>
<Link to={item.url}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
@@ -1,62 +0,0 @@
"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<typeof SidebarGroup>) {
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 (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
onClick={(e) => handleClick(e, item.onClick)}
>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
@@ -1,112 +0,0 @@
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 (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={avatarSrc} alt={displayName} />
<AvatarFallback className="rounded-lg p-0">
<img src={identiconSrc} alt={displayName} className="h-full w-full" />
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{displayName}</span>
<span className="truncate text-xs text-muted-foreground">
{user.email}
</span>
</div>
<IconDotsVertical className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={avatarSrc} alt={displayName} />
<AvatarFallback className="rounded-lg p-0">
<img src={identiconSrc} alt={displayName} className="h-full w-full" />
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{displayName}</span>
<span className="truncate text-xs text-muted-foreground">
{user.email}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
{theme === "dark" ? <IconSun /> : <IconMoon />}
Toggle {theme === "dark" ? "light mode" : "dark mode"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<IconLogout />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}
@@ -1,102 +0,0 @@
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 (
<div className="grid grid-cols-1 gap-4 px-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4 dark:*:data-[slot=card]:bg-card">
<Card className="@container/card">
<CardHeader>
<CardDescription>Total Revenue</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
$1,250.00
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingUp />
+12.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Trending up this month <IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">
Visitors for the last 6 months
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>New Customers</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
1,234
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingDown />
-20%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Down 20% this period <IconTrendingDown className="size-4" />
</div>
<div className="text-muted-foreground">
Acquisition needs attention
</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>Active Accounts</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
45,678
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingUp />
+12.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Strong user retention <IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">Engagement exceed targets</div>
</CardFooter>
</Card>
<Card className="@container/card">
<CardHeader>
<CardDescription>Growth Rate</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
4.5%
</CardTitle>
<CardAction>
<Badge variant="outline">
<IconTrendingUp />
+4.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<div className="line-clamp-1 flex gap-2 font-medium">
Steady performance increase <IconTrendingUp className="size-4" />
</div>
<div className="text-muted-foreground">Meets growth projections</div>
</CardFooter>
</Card>
</div>
)
}
@@ -1,81 +0,0 @@
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-none sm:max-w-[800px] w-full p-0 gap-0 overflow-hidden h-dvh sm:h-[600px] flex flex-col sm:flex-row rounded-none sm:rounded-[12px] border-border bg-background shadow-lg">
<DialogTitle className="sr-only">Settings</DialogTitle>
{/* Left Sidebar / Top Nav on mobile */}
<div className="w-full sm:w-[240px] border-b sm:border-b-0 sm:border-r border-border bg-muted/20 p-2 sm:p-4 flex flex-row sm:flex-col shrink-0 overflow-x-auto sm:overflow-x-visible sm:overflow-y-auto hide-scrollbar sm:show-scrollbar">
<div className="flex flex-row sm:flex-col gap-1 w-full">
<div className="text-xs font-semibold text-muted-foreground px-2 py-1 tracking-wider mb-1 hidden sm:block">
Settings
</div>
<button
className={cn(
"flex items-center whitespace-nowrap gap-2 px-3 sm:px-2 py-1.5 rounded-md text-sm font-medium transition-colors cursor-pointer shrink-0",
"bg-accent/50 text-accent-foreground", // Currently active tab
)}
>
<IconSettings className="size-4 shrink-0" />
General
</button>
</div>
</div>
{/* Right Content */}
<div className="flex-1 overflow-y-auto p-6 bg-background relative">
<div className="max-w-xl flex flex-col gap-6">
<div className="flex flex-col gap-1 border-b border-border pb-6">
<h2 className="text-xl font-bold tracking-tight">General</h2>
<p className="text-muted-foreground text-sm">
Choose how you want your dashboard to look and behave.
</p>
</div>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<h3 className="text-sm font-semibold text-muted-foreground">
Appearance
</h3>
<div className="flex items-center justify-between py-2">
<div className="flex flex-col gap-1">
<Label htmlFor="dark-mode" className="text-sm font-medium">
Dark Mode
</Label>
<p className="text-xs text-muted-foreground">
Enable dark theme for the dashboard
</p>
</div>
<Switch
id="dark-mode"
checked={theme === "dark"}
onCheckedChange={(checked) =>
setTheme(checked ? "dark" : "light")
}
/>
</div>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,30 +0,0 @@
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { SidebarTrigger } from "@/components/ui/sidebar"
export function SiteHeader() {
return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mx-2 data-[orientation=vertical]:h-4"
/>
<h1 className="text-base font-medium">Documents</h1>
<div className="ml-auto flex items-center gap-2">
<Button variant="ghost" asChild size="sm" className="hidden sm:flex">
<a
href="https://github.com/shadcn-ui/ui/tree/main/apps/v4/app/(examples)/dashboard"
rel="noopener noreferrer"
target="_blank"
className="dark:text-foreground"
>
GitHub
</a>
</Button>
</div>
</div>
</header>
)
}
@@ -1,6 +0,0 @@
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
@@ -1,196 +0,0 @@
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<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
overlayClassName,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
overlayClassName?: string
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay className={overlayClassName} />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
@@ -1,66 +0,0 @@
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<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
@@ -1,107 +0,0 @@
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<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg";
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-accent font-semibold text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className,
)}
{...props}
/>
);
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>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 (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className,
)}
{...props}
/>
);
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>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,
};
@@ -1,48 +0,0 @@
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<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
@@ -1,109 +0,0 @@
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 <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
@@ -1,64 +0,0 @@
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<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
@@ -1,92 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -1,355 +0,0 @@
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<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
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 (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
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 (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium text-foreground tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
@@ -1,30 +0,0 @@
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
@@ -1,158 +0,0 @@
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
overlayClassName,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
overlayClassName?: string
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -1,133 +0,0 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content fixed z-50 flex h-auto flex-col bg-background",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
@@ -1,257 +0,0 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
@@ -1,246 +0,0 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
@@ -1,21 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-10 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-primary transition-colors",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Input };
@@ -1,24 +0,0 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
@@ -1,188 +0,0 @@
import * as React from "react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-10 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
@@ -1,28 +0,0 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
@@ -1,143 +0,0 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
@@ -1,727 +0,0 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import { Slot } from "radix-ui";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className,
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className,
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default:
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors hover:bg-accent",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot.Root : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot.Root : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};
@@ -1,13 +0,0 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props}
/>
)
}
export { Skeleton }
@@ -1,38 +0,0 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
@@ -1,16 +0,0 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }
@@ -1,33 +0,0 @@
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
@@ -1,115 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, containerClassName, containerStyle, ...props }: React.ComponentProps<"table"> & { containerClassName?: string; containerStyle?: React.CSSProperties }) {
return (
<div
data-slot="table-container"
className={cn("relative w-full overflow-auto", containerClassName)}
style={containerStyle}
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
@@ -1,91 +0,0 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Tabs as TabsPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className,
)}
{...props}
/>
);
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-10 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
},
);
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className,
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
@@ -1,83 +0,0 @@
"use client"
import * as React from "react"
import { type VariantProps } from "class-variance-authority"
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
}
>({
size: "default",
variant: "default",
spacing: 0,
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }
@@ -1,47 +0,0 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Toggle as TogglePrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground 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 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 min-w-9 px-2",
sm: "h-8 min-w-8 px-1.5",
lg: "h-12 min-w-10 px-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };
@@ -1,55 +0,0 @@
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
@@ -1,88 +0,0 @@
import {
createContext,
useContext,
useState,
useEffect,
type ReactNode,
} from "react";
import { UsersApi } from "@/api/UsersApi";
import type { UserInfo } from "@/types";
interface AuthContextType {
user: UserInfo | null;
isLoading: boolean;
loginState: (user: UserInfo) => void;
logoutState: () => void;
checkSession: (showLoading?: boolean) => Promise<boolean>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<UserInfo | null>(null);
const [isLoading, setIsLoading] = useState(true);
const checkSession = async (showLoading = true): Promise<boolean> => {
if (showLoading) setIsLoading(true);
try {
const api = new UsersApi();
const response = await api.GetAppState();
const data = response.data;
if (response.success && data?.is_logged_in && data.maybe_user_info) {
if (!data.permissions?.is_moderator) {
await api.Logout();
setUser(null);
return false;
}
const info = data.maybe_user_info;
setUser({
id: info.user_token,
username: info.username,
display_name: info.display_name,
email: "",
gravatar_url: info.gravatar_hash
? `https://gravatar.com/avatar/${info.gravatar_hash}?s=200&d=404`
: undefined,
});
return true;
} else {
setUser(null);
return false;
}
} catch (error) {
console.error("Failed to check session", error);
setUser(null);
return false;
} finally {
if (showLoading) setIsLoading(false);
}
};
useEffect(() => {
checkSession();
}, []);
const loginState = (userData: UserInfo) => {
setUser(userData);
};
const logoutState = () => {
setUser(null);
};
return (
<AuthContext.Provider
value={{ user, isLoading, loginState, logoutState, checkSession }}
>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
@@ -1,19 +0,0 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
@@ -1,61 +0,0 @@
import { useState, useEffect } from "react";
interface CountryInfo {
name: string;
code: string;
}
const countryCache = new Map<string, CountryInfo>();
export function useCountryFromIP(ip?: string) {
const [country, setCountry] = useState<string | null>(null);
const [countryCode, setCountryCode] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
useEffect(() => {
if (!ip || ip === "N/A" || ip === "Unknown") {
setCountry(null);
setCountryCode(null);
return;
}
if (countryCache.has(ip)) {
const cached = countryCache.get(ip)!;
setCountry(cached.name);
setCountryCode(cached.code);
return;
}
setIsLoading(true);
Promise.all([
fetch(`https://get.geojs.io/v1/ip/country/full/${ip}`).then((res) =>
res.ok ? res.text() : Promise.reject(),
),
fetch(`https://get.geojs.io/v1/ip/country/${ip}`).then((res) =>
res.ok ? res.text() : Promise.reject(),
),
])
.then(([nameRaw, codeRaw]) => {
const name = nameRaw.trim();
const code = codeRaw.trim().toUpperCase();
const info: CountryInfo = {
name: name && name !== "undefined" ? name : "Unknown",
code: code && code !== "undefined" ? code : "",
};
countryCache.set(ip, info);
setCountry(info.name);
setCountryCode(info.code);
})
.catch(() => {
const info: CountryInfo = { name: "Unknown", code: "" };
countryCache.set(ip, info);
setCountry("Unknown");
setCountryCode("");
})
.finally(() => {
setIsLoading(false);
});
}, [ip]);
return { country, countryCode, isLoading };
}
@@ -1,12 +0,0 @@
import { useEffect } from "react";
const SITE_TITLE = "ArtCraft Admin Dashboard";
export function usePageTitle(title?: string) {
useEffect(() => {
document.title = title ? `${title} - ${SITE_TITLE}` : SITE_TITLE;
return () => {
document.title = SITE_TITLE;
};
}, [title]);
}
@@ -1,36 +0,0 @@
import { useRef, useState, useEffect, useCallback } from "react";
/**
* Measures an element's position in the viewport and returns the
* available height from that point to the bottom of the window.
* Uses a callback ref so it recalculates when the element mounts.
* Also recalculates on window resize.
*/
export function useTableHeight(bottomPadding = 36, minHeight = 200) {
const elRef = useRef<HTMLDivElement | null>(null);
const [height, setHeight] = useState<number | undefined>();
const calculate = useCallback(() => {
const el = elRef.current;
if (!el) return;
const top = el.getBoundingClientRect().top;
const available = window.innerHeight - top - bottomPadding;
setHeight(Math.max(available, minHeight));
}, [bottomPadding, minHeight]);
// Callback ref: fires when element mounts/unmounts
const ref = useCallback(
(node: HTMLDivElement | null) => {
elRef.current = node;
if (node) requestAnimationFrame(calculate);
},
[calculate],
);
useEffect(() => {
window.addEventListener("resize", calculate);
return () => window.removeEventListener("resize", calculate);
}, [calculate]);
return { ref, height };
}
@@ -1,136 +0,0 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: "Outfit", sans-serif;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.1749 0.0082 285.52);
--card: oklch(1 0 0);
--card-foreground: oklch(0.1749 0.0082 285.52);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.1749 0.0082 285.52);
--primary: oklch(0.5524 0.2294 262.27);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.1749 0.0082 285.52);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(92.485% 0.03612 261.334);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.1749 0.0082 285.52);
--foreground: oklch(0.985 0 0);
--card: oklch(0.2282 0.0077 285.78);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.2282 0.0077 285.78);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.5524 0.2294 262.27);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(30.658% 0.01085 278.201);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(28.296% 0.00911 285.76);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 10%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.2282 0.0077 285.78);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(32.288% 0.0378 259.445);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: "Outfit", sans-serif;
}
.font-outfit {
font-family: "Outfit", sans-serif;
}
}
@@ -1,213 +0,0 @@
import { Outlet, Navigate, useLocation, Link } from "react-router-dom";
import { useAuth } from "@/context/AuthContext";
import { AppSidebar } from "@/components/app-sidebar";
import {
SidebarProvider,
SidebarInset,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
interface Crumb {
label: string;
href?: string;
}
function useBreadcrumbs(): Crumb[] {
const { pathname } = useLocation();
if (pathname === "/") {
return [{ label: "Dashboard" }];
}
if (pathname === "/stripe-lookup") {
return [
{ label: "Dashboard", href: "/" },
{ label: "Stripe Lookup" },
];
}
if (pathname === "/user/search") {
return [
{ label: "Dashboard", href: "/" },
{ label: "User Search" },
];
}
if (pathname === "/user-signups") {
return [
{ label: "Dashboard", href: "/" },
{ label: "User Signups" },
];
}
if (pathname === "/subscriber-signups") {
return [
{ label: "Dashboard", href: "/" },
{ label: "Subscriber Signups" },
];
}
if (pathname === "/feature-flags") {
return [
{ label: "Dashboard", href: "/" },
{ label: "Feature Flags" },
];
}
if (pathname === "/referrals") {
return [
{ label: "Dashboard", href: "/" },
{ label: "Referrals" },
];
}
if (pathname === "/moderation/job-search") {
return [
{ label: "Dashboard", href: "/" },
{ label: "Search Job by Token" },
];
}
const jobInfoMatch = pathname.match(/^\/moderation\/job\/(.+)$/);
if (jobInfoMatch) {
return [
{ label: "Dashboard", href: "/" },
{ label: "Search Job by Token", href: "/moderation/job-search" },
{ label: "Job" },
];
}
if (pathname === "/moderation/debug-logs-search") {
return [
{ label: "Dashboard", href: "/" },
{ label: "Search Debug Logs" },
];
}
const debugLogsMatch = pathname.match(/^\/moderation\/debug-logs\/(.+)$/);
if (debugLogsMatch) {
return [
{ label: "Dashboard", href: "/" },
{ label: "Search Debug Logs", href: "/moderation/debug-logs-search" },
{ label: "Debug Logs" },
];
}
const mediaMatch = pathname.match(/^\/explore\/media\/(.+)$/);
if (mediaMatch) {
return [
{ label: "Dashboard", href: "/" },
{ label: "Explore Media", href: "/explore/media" },
{ label: `Media` },
];
}
if (pathname === "/explore/media") {
return [
{ label: "Dashboard", href: "/" },
{ label: "Explore Media" },
];
}
const jobsMatch = pathname.match(/^\/user\/profile\/([^/]+)\/jobs$/);
if (jobsMatch) {
const username = decodeURIComponent(jobsMatch[1]);
return [
{ label: "Dashboard", href: "/" },
{ label: "User Search", href: "/user/search" },
{ label: `@${username}`, href: `/user/profile/${username}` },
{ label: "Jobs" },
];
}
const walletMatch = pathname.match(/^\/user\/profile\/([^/]+)\/wallet$/);
if (walletMatch) {
const username = decodeURIComponent(walletMatch[1]);
return [
{ label: "Dashboard", href: "/" },
{ label: "User Search", href: "/user/search" },
{ label: `@${username}`, href: `/user/profile/${username}` },
{ label: "Wallet" },
];
}
const referralsMatch = pathname.match(/^\/user\/profile\/([^/]+)\/referrals$/);
if (referralsMatch) {
const username = decodeURIComponent(referralsMatch[1]);
return [
{ label: "Dashboard", href: "/" },
{ label: "User Search", href: "/user/search" },
{ label: `@${username}`, href: `/user/profile/${username}` },
{ label: "Referrals" },
];
}
const profileMatch = pathname.match(/^\/user\/profile\/(.+)$/);
if (profileMatch) {
const username = decodeURIComponent(profileMatch[1]);
return [
{ label: "Dashboard", href: "/" },
{ label: "User Search", href: "/user/search" },
{ label: `@${username}` },
];
}
return [{ label: "Dashboard", href: "/" }];
}
export function DashboardLayout() {
const { user, isLoading } = useAuth();
const crumbs = useBreadcrumbs();
if (isLoading) {
return (
<div className="flex h-screen w-full items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
}
if (!user) {
return <Navigate to={`/login?redirect=${encodeURIComponent(window.location.pathname)}`} replace />;
}
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumb>
<BreadcrumbList>
{crumbs.map((crumb, i) => (
<BreadcrumbItem key={i}>
{i > 0 && <BreadcrumbSeparator />}
{crumb.href ? (
<BreadcrumbLink asChild>
<Link to={crumb.href}>{crumb.label}</Link>
</BreadcrumbLink>
) : (
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
)}
</BreadcrumbItem>
))}
</BreadcrumbList>
</Breadcrumb>
</header>
<main className="flex flex-1 flex-col gap-4 p-4 lg:gap-6 lg:p-6">
<Outlet />
</main>
</SidebarInset>
</SidebarProvider>
);
}
@@ -1,139 +0,0 @@
import {
IconHorse,
IconFlask,
IconBug,
IconShieldLock,
IconVideo,
IconMusic,
IconMicrophone,
IconPhoto,
IconMovie,
IconBrain,
IconWand,
IconSparkles,
IconRocket,
IconBolt,
IconStar,
IconDownload,
IconUpload,
IconMessage,
IconShare,
IconCreditCard,
IconShoppingCart,
IconWorld,
IconLock,
IconEye,
IconPalette,
IconCode,
IconCloud,
IconDatabase,
IconCpu,
IconGauge,
IconUsers,
IconFile,
IconCube,
IconBrush,
IconPrinter,
IconClock,
IconBell,
IconGift,
IconCompass,
IconEdit,
IconSettings,
IconSearch,
IconBookmark,
IconTools,
IconPuzzle,
IconBulb,
IconDiamond,
IconHexagon,
IconToggleRight,
IconBadge,
IconComponents,
} from "@tabler/icons-react";
type FlagIcon = typeof IconHorse;
// Rules are evaluated in order; first match wins. Keys & names are normalized
// so that `happy_horse` and `happy-horse` match `\bhorse\b`.
const rules: Array<[RegExp, FlagIcon]> = [
[/\bhorse\b/, IconHorse],
[/\b(beta|experiment|experimental|canary|preview)\b/, IconFlask],
[/\b(debug|dev|developer)\b/, IconBug],
[/\b(admin|staff|moderator|mod)\b/, IconShieldLock],
[/\b(explore|discover|browse)\b/, IconCompass],
[/\b(studio|workspace|workshop|canvas|editor)\b/, IconBrush],
[/\b(media|gallery|library|album)\b/, IconPhoto],
[/\b(movie|film|cinema|reel)\b/, IconMovie],
[/\bvideo\b/, IconVideo],
[/\b(audio|music|sound|track)\b/, IconMusic],
[/\b(voice|speech|tts|microphone|mic)\b/, IconMicrophone],
[/\b(image|photo|picture|img)\b/, IconPhoto],
[/\b(ai|ml|neural)\b/, IconBrain],
[/\b(magic|wand|smart|generate|gen)\b/, IconWand],
[/\b(sparkle|shiny|glow|new)\b/, IconSparkles],
[/\b(fast|speed|performance|turbo|boost)\b/, IconRocket],
[/\b(power|bolt|thunder|energy)\b/, IconBolt],
[/\b(premium|pro|vip|favorite|featured)\b/, IconStar],
[/\b(download|export)\b/, IconDownload],
[/\b(upload|import)\b/, IconUpload],
[/\b(chat|message|dm|comment|prompt)\b/, IconMessage],
[/\b(referral|referrals|invite|invites|share|social|post)\b/, IconShare],
[/\b(credit|billing|payment|pay|checkout|subscription|sub)\b/, IconCreditCard],
[/\b(store|shop|market|cart)\b/, IconShoppingCart],
[/\b(world|public|global|web)\b/, IconWorld],
[/\b(private|secret|hidden|lock)\b/, IconLock],
[/\b(view|preview|show|visible|reveal)\b/, IconEye],
[/\b(theme|color|palette|design|style)\b/, IconPalette],
[/\b(brush|paint|draw|art)\b/, IconBrush],
[/\b(print|printer)\b/, IconPrinter],
[/\b(code|api|endpoint|script)\b/, IconCode],
[/\bcloud\b/, IconCloud],
[/\b(data|database|storage|table)\b/, IconDatabase],
[/\b(cpu|compute|gpu|processor|render)\b/, IconCpu],
[/\b(gauge|metric|monitor|analytics|stat)\b/, IconGauge],
[/\b(user|team|collab|collaboration|member|profile)\b/, IconUsers],
[/\b(file|document|doc)\b/, IconFile],
[/\b(3d|dimensional|mesh|model)\b/, IconCube],
[/\b(time|clock|schedule|timer|history)\b/, IconClock],
[/\b(notification|bell|alert)\b/, IconBell],
[/\b(gift|reward|promo|promotion|bonus)\b/, IconGift],
[/\b(edit|update|modify)\b/, IconEdit],
[/\b(settings|config|configuration|preference)\b/, IconSettings],
[/\b(search|find|query|lookup)\b/, IconSearch],
[/\b(bookmark|save|favorite)\b/, IconBookmark],
[/\b(tool|toolkit|utility)\b/, IconTools],
[/\b(component|module|plugin)\b/, IconComponents],
[/\b(feature|toggle|switch|enable)\b/, IconToggleRight],
];
// Visually-varied fallback set so no flag ends up on a plain flag icon.
const fallbackIcons: FlagIcon[] = [
IconPuzzle,
IconBulb,
IconDiamond,
IconHexagon,
IconToggleRight,
IconBadge,
];
function hashString(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) | 0;
}
return Math.abs(h);
}
export function getFeatureFlagIcon(flag: {
key: string;
full_name?: string;
}): FlagIcon {
const normalized = `${flag.key} ${flag.full_name ?? ""}`
.toLowerCase()
.replace(/[_-]/g, " ");
for (const [pattern, icon] of rules) {
if (pattern.test(normalized)) return icon;
}
return fallbackIcons[hashString(flag.key) % fallbackIcons.length];
}
@@ -1,26 +0,0 @@
import { createAvatar } from "@dicebear/core";
import { identicon } from "@dicebear/collection";
const cache = new Map<string, string>();
export function getIdenticonUrl(seed: string): string {
const cached = cache.get(seed);
if (cached) return cached;
const avatar = createAvatar(identicon, {
seed,
size: 128,
backgroundColor: [
"eef2ff", "f0fdf4", "fef2f2", "fdf4ff", "fff7ed",
"ecfeff", "fefce8", "fdf2f8", "f0f9ff", "f5f3ff",
],
rowColor: [
"4f46e5", "16a34a", "dc2626", "9333ea", "ea580c",
"0891b2", "ca8a04", "db2777", "0284c7", "7c3aed",
],
});
const url = avatar.toDataUri();
cache.set(seed, url);
return url;
}
@@ -1,12 +0,0 @@
const NAMESPACE_LABELS: Record<string, string> = {
artcraft: "ArtCraft",
fakeyou: "FakeYou",
};
export function formatNamespace(ns: string): string {
return NAMESPACE_LABELS[ns.toLowerCase()] ?? ns;
}
export function stripeCustomerDashboardUrl(stripeCustomerId: string): string {
return `https://dashboard.stripe.com/customers/${encodeURIComponent(stripeCustomerId)}`;
}
@@ -1,20 +0,0 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Turns "complete_success" / "image-generation" / "fooBar" into "Complete Success".
// Falls back to the input untouched if it's already mixed-case prose.
export function humanize(value: string): string {
if (!value) return value
return value
.replace(/[_-]+/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/\s+/g, " ")
.trim()
.split(" ")
.map((w) => (w ? w[0].toUpperCase() + w.slice(1).toLowerCase() : w))
.join(" ")
}
@@ -1,10 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
@@ -1,169 +0,0 @@
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { IconChartBar, IconDashboard, IconWorld } from "@tabler/icons-react";
import { usePageTitle } from "@/hooks/usePageTitle";
const activeUsersData = [
{ date: "02/05", activeUsers: 812 },
{ date: "02/06", activeUsers: 945 },
{ date: "02/07", activeUsers: 1023 },
{ date: "02/08", activeUsers: 876 },
{ date: "02/09", activeUsers: 654 },
{ date: "02/10", activeUsers: 598 },
{ date: "02/11", activeUsers: 901 },
{ date: "02/12", activeUsers: 1102 },
{ date: "02/13", activeUsers: 1045 },
{ date: "02/14", activeUsers: 978 },
{ date: "02/15", activeUsers: 1134 },
{ date: "02/16", activeUsers: 756 },
{ date: "02/17", activeUsers: 689 },
{ date: "02/18", activeUsers: 1023 },
{ date: "02/19", activeUsers: 1187 },
{ date: "02/20", activeUsers: 1056 },
{ date: "02/21", activeUsers: 998 },
{ date: "02/22", activeUsers: 1210 },
{ date: "02/23", activeUsers: 834 },
{ date: "02/24", activeUsers: 712 },
{ date: "02/25", activeUsers: 967 },
{ date: "02/26", activeUsers: 1089 },
{ date: "02/27", activeUsers: 1145 },
{ date: "02/28", activeUsers: 1034 },
{ date: "03/01", activeUsers: 1267 },
{ date: "03/02", activeUsers: 890 },
{ date: "03/03", activeUsers: 756 },
{ date: "03/04", activeUsers: 1123 },
{ date: "03/05", activeUsers: 1198 },
{ date: "03/06", activeUsers: 704 },
];
const countryData = [
{ country: "United States", activeUsers: 704 },
{ country: "India", activeUsers: 105 },
{ country: "Canada", activeUsers: 100 },
{ country: "China", activeUsers: 74 },
{ country: "Brazil", activeUsers: 59 },
{ country: "Argentina", activeUsers: 57 },
{ country: "Australia", activeUsers: 51 },
];
const activeUsersConfig = {
activeUsers: {
label: "Active Users",
color: "hsl(221, 83%, 53%)",
},
} satisfies ChartConfig;
const countryConfig = {
activeUsers: {
label: "Active Users",
color: "hsl(142, 71%, 45%)",
},
} satisfies ChartConfig;
const totalActiveUsers = activeUsersData.reduce(
(sum, d) => sum + d.activeUsers,
0,
);
export function DashboardHome() {
usePageTitle("Dashboard");
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<IconDashboard className="size-6 text-muted-foreground" />
Dashboard Overview
</h1>
<p className="text-muted-foreground">Welcome to the admin dashboard.</p>
</div>
<div className="grid gap-4 lg:grid-cols-5">
{/* Active Users Over Time */}
<Card className="lg:col-span-3">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div className="flex items-center gap-2">
<IconChartBar className="size-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium">
Active Users (DUMMY CONTENT RIGHT NOW)
</CardTitle>
</div>
<span className="text-2xl font-bold tabular-nums">
{totalActiveUsers.toLocaleString()}
</span>
</CardHeader>
<CardContent>
<ChartContainer
config={activeUsersConfig}
className="aspect-auto h-[300px] w-full"
>
<BarChart data={activeUsersData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
interval="preserveStartEnd"
/>
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
<ChartTooltip
content={<ChartTooltipContent hideLabel={false} />}
/>
<Bar
dataKey="activeUsers"
fill="var(--color-activeUsers)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
{/* Users by Country */}
<Card className="lg:col-span-2">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<IconWorld className="size-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium">
Users by Country (DUMMY CONTENT RIGHT NOW)
</CardTitle>
</div>
</CardHeader>
<CardContent>
<ChartContainer
config={countryConfig}
className="aspect-auto h-[300px] w-full"
>
<BarChart data={countryData} layout="vertical">
<CartesianGrid horizontal={false} />
<XAxis type="number" tickLine={false} axisLine={false} />
<YAxis
type="category"
dataKey="country"
tickLine={false}
axisLine={false}
tickMargin={8}
width={100}
/>
<ChartTooltip
content={<ChartTooltipContent hideLabel={false} />}
/>
<Bar
dataKey="activeUsers"
fill="var(--color-activeUsers)"
radius={[0, 4, 4, 0]}
/>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
</div>
</div>
);
}
@@ -1,415 +0,0 @@
import { useState, useEffect, useMemo } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ModerationApi } from "@/api/ModerationApi";
import type { DebugLog } from "@/types";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Input } from "@/components/ui/input";
import { usePageTitle } from "@/hooks/usePageTitle";
import { humanize } from "@/lib/utils";
import { RawTag } from "@/components/RawTag";
import {
IconAlertCircle,
IconArrowLeft,
IconBug,
IconCheck,
IconCopy,
IconChevronDown,
IconChevronRight,
IconRefresh,
IconSearch,
} from "@tabler/icons-react";
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
});
}
function getLogTypeBadgeProps(logType: string): {
variant: "default" | "secondary" | "destructive" | "outline";
className: string;
} {
switch (logType) {
case "http_request":
return {
variant: "secondary",
className: "bg-blue-500/10 text-blue-400 border-transparent",
};
case "fal_request":
return {
variant: "secondary",
className: "bg-purple-500/10 text-purple-400 border-transparent",
};
case "fal_queue":
return {
variant: "secondary",
className: "bg-fuchsia-500/10 text-fuchsia-400 border-transparent",
};
case "fal_webhook":
return {
variant: "secondary",
className: "bg-amber-500/10 text-amber-400 border-transparent",
};
case "kinovi_request":
return {
variant: "secondary",
className: "bg-cyan-500/10 text-cyan-400 border-transparent",
};
default:
return { variant: "outline", className: "text-muted-foreground" };
}
}
const DEFAULT_LIMIT = 100;
export function DebugLogs() {
const { eventToken } = useParams<{ eventToken: string }>();
usePageTitle(eventToken ? `Debug ${eventToken.slice(0, 8)}…` : "Debug Logs");
const navigate = useNavigate();
const [logs, setLogs] = useState<DebugLog[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [filter, setFilter] = useState("");
const copy = (value: string, id: string) => {
navigator.clipboard.writeText(value).then(() => {
setCopiedId(id);
setTimeout(() => setCopiedId((c) => (c === id ? null : c)), 2000);
});
};
const loadLogs = async () => {
if (!eventToken) return;
setIsLoading(true);
setError(null);
try {
const api = new ModerationApi();
const resp = await api.ListDebugLogs(eventToken, DEFAULT_LIMIT);
if (resp.success && resp.data) {
setLogs(resp.data.debug_logs);
} else {
setError(resp.errorMessage || "Failed to load debug logs");
}
} catch (err: any) {
setError(err.message || "Failed to load debug logs");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadLogs();
}, [eventToken]);
const filteredLogs = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return logs;
return logs.filter(
(log) =>
log.message.toLowerCase().includes(q) ||
log.debug_log_type.toLowerCase().includes(q),
);
}, [logs, filter]);
return (
<div className="flex flex-col gap-8 pb-10">
{/* Header */}
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(-1)}
className="p-0! h-auto w-auto hover:bg-transparent! text-foreground/70 hover:text-foreground/50"
>
<IconArrowLeft className="size-6" />
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<IconBug className="size-6 text-muted-foreground" />
Debug Logs
{!isLoading && (
<span className="text-sm font-normal text-muted-foreground ml-1">
({logs.length})
</span>
)}
</h1>
{eventToken && (
<p className="text-muted-foreground text-sm font-mono mt-1 truncate">
Event {eventToken}
</p>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={loadLogs}
disabled={isLoading}
>
<IconRefresh className="size-4" />
Refresh
</Button>
<Button
variant="outline"
size="sm"
onClick={() => copy(window.location.href, "page_link")}
>
{copiedId === "page_link" ? (
<IconCheck className="size-4 text-emerald-400" />
) : (
<IconCopy className="size-4" />
)}
Copy Link
</Button>
</div>
</div>
{error && !isLoading && (
<Alert variant="destructive" className="max-w-xl">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Filter */}
{logs.length > 0 && (
<div className="relative max-w-md">
<IconSearch className="size-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Filter logs..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="pl-9"
/>
</div>
)}
{isLoading && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="rounded-xl border bg-card shadow-sm flex flex-col p-4 gap-3"
>
<div className="flex items-center gap-3">
<Skeleton className="h-5 w-24 rounded-full" />
<Skeleton className="h-4 w-40" />
</div>
<Skeleton className="h-20 w-full" />
</div>
))}
</div>
)}
{!isLoading && logs.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-20 bg-muted/20 border border-dashed rounded-xl">
<IconBug className="size-10 text-muted-foreground mb-4 opacity-20" />
<p className="text-muted-foreground">
No debug logs found for this event token.
</p>
</div>
)}
{!isLoading && filteredLogs.length === 0 && logs.length > 0 && (
<div className="flex flex-col items-center justify-center py-20 bg-muted/20 border border-dashed rounded-xl">
<p className="text-muted-foreground text-sm">
No logs match your filter.
</p>
</div>
)}
{!isLoading && filteredLogs.length > 0 && (
<div className="flex flex-col gap-3">
{filteredLogs.map((log) => (
<DebugLogCard
key={log.id}
log={log}
copiedId={copiedId}
onCopy={copy}
/>
))}
</div>
)}
</div>
);
}
interface DebugLogCardProps {
log: DebugLog;
copiedId: string | null;
onCopy: (value: string, id: string) => void;
}
function DebugLogCard({ log, copiedId, onCopy }: DebugLogCardProps) {
const [expanded, setExpanded] = useState(true);
const badge = getLogTypeBadgeProps(log.debug_log_type);
const copyId = `log_${log.id}`;
return (
<div className="rounded-xl border bg-card text-card-foreground shadow-sm overflow-hidden">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center gap-3 p-4 hover:bg-muted/30 transition-colors text-left"
>
{expanded ? (
<IconChevronDown className="size-4 shrink-0 text-muted-foreground" />
) : (
<IconChevronRight className="size-4 shrink-0 text-muted-foreground" />
)}
<Badge variant={badge.variant} className={badge.className}>
{humanize(log.debug_log_type)}
</Badge>
<RawTag value={log.debug_log_type} />
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
{formatDateTime(log.created_at)}
</span>
<span className="text-xs text-muted-foreground/60 font-mono ml-auto">
#{log.id}
</span>
<span
className="ml-2 inline-flex items-center"
onClick={(e) => {
e.stopPropagation();
onCopy(log.message, copyId);
}}
>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
title="Copy payload"
>
{copiedId === copyId ? (
<IconCheck className="size-3.5 text-emerald-400" />
) : (
<IconCopy className="size-3.5" />
)}
</Button>
</span>
</button>
{expanded && (
<div className="border-t border-border/50 p-4 flex flex-col gap-3">
{log.maybe_creator_user_token && (
<div className="text-xs text-muted-foreground">
<span className="uppercase tracking-wider font-medium">
User Token:
</span>{" "}
<span className="font-mono text-foreground/80">
{log.maybe_creator_user_token}
</span>
</div>
)}
<PrettyPayload raw={log.message} />
</div>
)}
</div>
);
}
function tryParseJson(raw: string): unknown | undefined {
const trimmed = raw.trim();
if (
!(
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"))
)
) {
return undefined;
}
try {
return JSON.parse(trimmed);
} catch {
return undefined;
}
}
function PrettyPayload({ raw }: { raw: string }) {
const parsed = useMemo(() => tryParseJson(raw), [raw]);
if (parsed !== undefined) {
return (
<pre className="text-xs font-mono whitespace-pre-wrap wrap-break-word bg-muted/40 p-4 rounded-lg max-h-[600px] overflow-auto text-foreground/90">
{JSON.stringify(parsed, null, 2)}
</pre>
);
}
// Likely a Rust struct debug print or plain text. Re-indent loosely so it's readable.
const formatted = formatRustyDebug(raw);
return (
<pre className="text-xs font-mono whitespace-pre-wrap wrap-break-word bg-muted/40 p-4 rounded-lg max-h-[600px] overflow-auto text-foreground/80">
{formatted}
</pre>
);
}
// Lightweight reformatter for Rust's `Debug` output (e.g. `Foo { a: 1, b: Bar { c: 2 } }`).
// Adds line breaks and indentation around `{`, `}`, `[`, `]`, and top-level commas.
// Falls back gracefully for anything it doesn't understand.
function formatRustyDebug(raw: string): string {
const looksRusty = /[{[].+[}\]]/s.test(raw);
if (!looksRusty) return raw;
let depth = 0;
let out = "";
let inString = false;
let stringChar: '"' | "'" | null = null;
const indent = () => " ".repeat(depth);
for (let i = 0; i < raw.length; i++) {
const ch = raw[i];
const prev = raw[i - 1];
// String tracking (handle escapes)
if (inString) {
out += ch;
if (ch === stringChar && prev !== "\\") {
inString = false;
stringChar = null;
}
continue;
}
if (ch === '"' || ch === "'") {
inString = true;
stringChar = ch;
out += ch;
continue;
}
if (ch === "{" || ch === "[") {
depth++;
out += ch + "\n" + indent();
continue;
}
if (ch === "}" || ch === "]") {
depth = Math.max(0, depth - 1);
out = out.replace(/[ \t]+$/, "");
out += "\n" + indent() + ch;
continue;
}
if (ch === "," && depth > 0) {
out += ",\n" + indent();
// Skip following whitespace
while (i + 1 < raw.length && raw[i + 1] === " ") i++;
continue;
}
out += ch;
}
return out.trim();
}
@@ -1,20 +0,0 @@
import { IconBug } from "@tabler/icons-react";
import { TokenSearchForm } from "@/components/TokenSearchForm";
import { usePageTitle } from "@/hooks/usePageTitle";
export function DebugLogsSearch() {
usePageTitle("Search Debug Logs");
return (
<TokenSearchForm
title="Search Debug Logs"
description="Look up every debug log row that shares a given event token. Useful when chasing a single request through the system."
inputLabel="Debug Event Token"
placeholder="Paste a debug event token..."
buttonLabel="Open"
icon={IconBug}
buildHref={(token) =>
`/moderation/debug-logs/${encodeURIComponent(token)}`
}
/>
);
}
@@ -1,338 +0,0 @@
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useSearchParams, useParams, useNavigate } from "react-router-dom";
import {
MediaApi,
type ExploreMediaFile,
type CursorPagination,
} from "@/api/MediaApi";
import { MediaCard } from "@/components/MediaCard";
import { MediaDetailModal } from "@/components/MediaDetailModal";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import {
IconAlertCircle,
IconCompass,
IconRefresh,
IconLoader2,
IconPhoto,
} from "@tabler/icons-react";
import { usePageTitle } from "@/hooks/usePageTitle";
const MEDIA_CLASS_OPTIONS = [
{ value: "", label: "All" },
{ value: "image", label: "Image" },
{ value: "video", label: "Video" },
{ value: "dimensional", label: "3D" },
] as const;
const PAGE_SIZE = 24;
export function ExploreMedia() {
usePageTitle("Explore Media");
const { mediaToken } = useParams<{ mediaToken?: string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
// Read filters from URL
const filterMediaClasses = searchParams.get("filter_media_classes") || "";
const filterMediaType = searchParams.get("filter_media_type") || "";
const includeUserUploads =
searchParams.get("include_user_uploads") === "true";
const [items, setItems] = useState<ExploreMediaFile[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pagination, setPagination] = useState<CursorPagination | null>(null);
const [selectedItem, setSelectedItem] = useState<ExploreMediaFile | null>(null);
const cancelledRef = useRef(false);
const observerRef = useRef<IntersectionObserver | null>(null);
const sentinelRef = useRef<HTMLDivElement | null>(null);
// Stable filter key to detect changes
const filterKey = useMemo(
() => `${filterMediaClasses}|${filterMediaType}|${includeUserUploads}`,
[filterMediaClasses, filterMediaType, includeUserUploads],
);
const loadData = useCallback(
async (cursor?: string, append = false) => {
if (!append) {
setIsLoading(true);
setError(null);
} else {
setIsLoadingMore(true);
}
try {
const api = new MediaApi();
const resp = await api.ListMediaFiles({
page_size: PAGE_SIZE,
cursor,
filter_media_classes: filterMediaClasses || "image,video,dimensional",
filter_media_type: filterMediaType || undefined,
include_user_uploads: includeUserUploads || undefined,
});
if (cancelledRef.current) return;
if (resp.success && resp.data) {
setItems((prev) => (append ? [...prev, ...resp.data!] : resp.data!));
setPagination(resp.pagination ?? null);
} else {
setError(resp.errorMessage || "Failed to load media");
}
} catch (err: any) {
if (!cancelledRef.current)
setError(err.message || "Failed to load media");
} finally {
if (!cancelledRef.current) {
setIsLoading(false);
setIsLoadingMore(false);
}
}
},
[filterMediaClasses, filterMediaType, includeUserUploads],
);
// Reset and load when filters change
useEffect(() => {
cancelledRef.current = false;
setItems([]);
setPagination(null);
loadData();
return () => {
cancelledRef.current = true;
};
}, [filterKey]); // eslint-disable-line react-hooks/exhaustive-deps
const nextCursor = pagination?.maybe_next ?? null;
const loadMore = useCallback(() => {
if (!isLoadingMore && nextCursor) {
loadData(nextCursor, true);
}
}, [nextCursor, isLoadingMore, loadData]);
// IntersectionObserver for infinite scroll
useEffect(() => {
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMore();
},
{ threshold: 0.1 },
);
if (sentinelRef.current) {
observerRef.current.observe(sentinelRef.current);
}
return () => observerRef.current?.disconnect();
}, [loadMore]);
// Sync URL mediaToken → selectedItem
useEffect(() => {
if (!mediaToken) {
setSelectedItem(null);
return;
}
// Try to find in already-loaded items
const found = items.find((i) => i.token === mediaToken);
if (found) {
setSelectedItem(found);
} else {
// Fetch single media file for direct URL access
const api = new MediaApi();
api.GetMediaFile(mediaToken).then((res) => {
if (res.success && res.data) {
setSelectedItem(res.data as unknown as ExploreMediaFile);
}
});
}
}, [mediaToken, items]);
const handleMediaSelect = useCallback(
(item: ExploreMediaFile) => {
setSelectedItem(item);
navigate(`/explore/media/${item.token}`, { replace: false });
},
[navigate],
);
const handleModalClose = useCallback(
(open: boolean) => {
if (!open) {
setSelectedItem(null);
navigate("/explore/media", { replace: true });
}
},
[navigate],
);
const handleRefresh = () => {
setItems([]);
setPagination(null);
loadData();
};
const updateFilter = (key: string, value: string | boolean) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (
value === "" ||
value === false ||
value === undefined ||
value === null
) {
next.delete(key);
} else {
next.set(key, String(value));
}
return next;
});
};
return (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center gap-4">
<div className="flex-1 flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<IconCompass className="size-6 text-muted-foreground" />
Explore Media
</h1>
<p className="text-muted-foreground">
Browse all latest media files across all users
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isLoading}
>
<IconRefresh
className={`size-4 ${isLoading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
{/* Sticky filters bar */}
<div className="sticky top-0 z-20 -mx-4 lg:-mx-6 px-4 lg:px-6 py-3 bg-background/80 backdrop-blur-md border-b">
<div className="flex flex-wrap items-center gap-3">
{/* Media class toggles */}
<div className="flex items-center gap-1.5">
{MEDIA_CLASS_OPTIONS.map((opt) => (
<Button
key={opt.value}
variant={
filterMediaClasses === opt.value ? "default" : "outline"
}
size="sm"
className="h-7 text-xs px-3"
onClick={() => updateFilter("filter_media_classes", opt.value)}
>
{opt.label}
</Button>
))}
</div>
{/* Media type filter (text input for comma-separated values) */}
{filterMediaType && (
<div className="flex items-center gap-1.5">
{filterMediaType.split(",").map((type) => (
<Badge
key={type}
variant="secondary"
className="text-[10px] cursor-pointer hover:bg-destructive/20"
onClick={() => {
const remaining = filterMediaType
.split(",")
.filter((t) => t !== type)
.join(",");
updateFilter("filter_media_type", remaining);
}}
>
{type} &times;
</Badge>
))}
</div>
)}
{/* Include user uploads toggle */}
<div className="flex items-center gap-2 ml-auto">
<Switch
id="include-uploads"
checked={includeUserUploads}
onCheckedChange={(checked) =>
updateFilter("include_user_uploads", checked)
}
/>
<Label htmlFor="include-uploads" className="text-xs cursor-pointer">
User uploads
</Label>
</div>
</div>
</div>
{/* Error */}
{error && (
<Alert variant="destructive" className="max-w-xl">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Grid */}
{isLoading ? (
<div className="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
{Array.from({ length: PAGE_SIZE }).map((_, i) => (
<Skeleton key={i} className="aspect-square rounded-xl" />
))}
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 bg-muted/20 border border-dashed rounded-xl">
<IconPhoto className="size-10 text-muted-foreground mb-4 opacity-20" />
<p className="text-muted-foreground">No media files found.</p>
</div>
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
{items.map((item) => (
<MediaCard key={item.token} item={item} showCreator onSelect={() => handleMediaSelect(item)} />
))}
</div>
{/* Sentinel for infinite scroll */}
{nextCursor && (
<div ref={sentinelRef} className="flex justify-center py-6">
{isLoadingMore ? (
<div className="flex items-center gap-2 text-muted-foreground">
<IconLoader2 className="size-4 animate-spin" />
Loading more...
</div>
) : (
<span className="text-muted-foreground/50 text-sm">
Scroll for more
</span>
)}
</div>
)}
</>
)}
<MediaDetailModal
item={selectedItem}
open={!!selectedItem}
onOpenChange={handleModalClose}
/>
</div>
);
}
@@ -1,285 +0,0 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import {
ModerationApi,
type FeatureFlagDescriptor,
type ModeratorUserLookupResponse,
} from "@/api/ModerationApi";
import { usePageTitle } from "@/hooks/usePageTitle";
import { UserFeatureFlagsDialog } from "@/components/UserFeatureFlagsDialog";
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 { getFeatureFlagIcon } from "@/lib/feature-flag-icon";
import {
IconFlag,
IconSearch,
IconAlertCircle,
IconArrowRight,
IconUser,
} from "@tabler/icons-react";
function getAvatarSrc(user: ModeratorUserLookupResponse): string | undefined {
if (user.email_gravatar_hash) {
return `https://gravatar.com/avatar/${user.email_gravatar_hash}?s=60&d=404`;
}
return undefined;
}
export function FeatureFlags() {
usePageTitle("Feature Flags");
const [availableFlags, setAvailableFlags] = useState<FeatureFlagDescriptor[]>(
[],
);
const [isLoadingFlags, setIsLoadingFlags] = useState(true);
const [flagsError, setFlagsError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [isSearching, setIsSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [searchResult, setSearchResult] =
useState<ModeratorUserLookupResponse | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
useEffect(() => {
let cancelled = false;
const api = new ModerationApi();
api
.ListAvailableFeatureFlags()
.then((resp) => {
if (cancelled) return;
if (resp.success && resp.data) {
setAvailableFlags(
[...resp.data.feature_flags].sort((a, b) =>
a.key.localeCompare(b.key),
),
);
} else {
setFlagsError(resp.errorMessage || "Failed to load feature flags");
}
})
.finally(() => {
if (!cancelled) setIsLoadingFlags(false);
});
return () => {
cancelled = true;
};
}, []);
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
const query = searchQuery.trim();
if (!query || isSearching) return;
setIsSearching(true);
setSearchError(null);
setSearchResult(null);
const api = new ModerationApi();
const resp = await api.UserLookup(query);
setIsSearching(false);
if (resp.success && resp.data?.maybe_user) {
setSearchResult(resp.data.maybe_user);
} else {
setSearchError(resp.errorMessage || "User not found");
}
};
return (
<div className="flex flex-col gap-6 pb-10">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<IconFlag className="size-6 text-muted-foreground" />
Feature Flags
</h1>
<p className="text-muted-foreground">
View all available feature flags and manage which ones are enabled for
individual users.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 max-w-5xl lg:items-start">
{/* Available Flags */}
<div className="rounded-xl border bg-card text-card-foreground shadow-sm flex flex-col p-5">
<h4 className="text-sm font-semibold mb-4 text-foreground/80 uppercase tracking-wider pb-3 border-b border-border/50 flex items-center gap-2 h-[45px] shrink-0">
<IconFlag className="size-4" />
Available Flags
{!isLoadingFlags && !flagsError && (
<span className="text-sm font-normal text-muted-foreground normal-case tracking-normal ml-1">
({availableFlags.length})
</span>
)}
</h4>
{isLoadingFlags ? (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="flex flex-col gap-1.5 pb-3 border-b border-dashed border-border/50"
>
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-full" />
</div>
))}
</div>
) : flagsError ? (
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>{flagsError}</AlertDescription>
</Alert>
) : availableFlags.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No feature flags are currently defined.
</p>
) : (
<ul className="flex flex-col divide-y divide-dashed divide-border/50 lg:max-h-[calc(100svh-320px)] lg:overflow-y-auto -mr-2 pr-2">
{availableFlags.map((flag) => {
const Icon = getFeatureFlagIcon(flag);
return (
<li
key={flag.key}
className="flex items-start gap-3 py-3 first:pt-0 last:pb-0"
>
<Icon className="size-4 text-muted-foreground shrink-0 mt-0.5" />
<div className="flex flex-col min-w-0 gap-1 flex-1">
<div className="flex items-baseline gap-2 flex-wrap">
{flag.full_name && (
<span className="text-sm font-semibold leading-tight">
{flag.full_name}
</span>
)}
<span className="text-[11px] font-mono text-muted-foreground break-all">
{flag.key}
</span>
</div>
{flag.description && (
<p className="text-xs text-muted-foreground leading-relaxed">
{flag.description}
</p>
)}
</div>
</li>
);
})}
</ul>
)}
</div>
{/* User Search */}
<div className="rounded-xl border bg-card text-card-foreground shadow-sm flex flex-col p-5">
<h4 className="text-sm font-semibold mb-4 text-foreground/80 uppercase tracking-wider pb-3 border-b border-border/50 flex items-center gap-2 h-[45px]">
<IconUser className="size-4" />
Manage User Flags
</h4>
<form
onSubmit={handleSearch}
className="flex items-center gap-2 mb-3"
>
<Input
type="text"
placeholder="Username, email, or token..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1"
/>
<Button
type="submit"
disabled={isSearching || !searchQuery.trim()}
>
<IconSearch className="size-4" />
{isSearching ? "Searching..." : "Find"}
</Button>
</form>
{isSearching && (
<div className="rounded-lg border bg-muted/20 p-3 flex items-center gap-3">
<Skeleton className="h-12 w-12 rounded-md" />
<div className="flex flex-col gap-1.5 flex-1">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-20" />
</div>
<Skeleton className="h-8 w-20 rounded-md" />
</div>
)}
{searchError && !isSearching && (
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>
{searchError.charAt(0).toUpperCase() + searchError.slice(1)}
</AlertDescription>
</Alert>
)}
{searchResult && !isSearching && (
<div className="rounded-lg border bg-muted/20 p-3 flex items-center gap-3">
<Avatar className="h-12 w-12 rounded-md shrink-0">
<AvatarImage
src={getAvatarSrc(searchResult)}
draggable={false}
/>
<AvatarFallback className="rounded-md p-0">
<img
src={getIdenticonUrl(searchResult.username)}
alt={searchResult.username}
className="h-full w-full"
draggable={false}
/>
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold leading-tight font-outfit truncate">
{searchResult.display_name || searchResult.username}
</span>
{searchResult.is_banned && (
<Badge variant="destructive" className="text-[10px]">
Banned
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground truncate">
@{searchResult.username}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Button size="sm" onClick={() => setDialogOpen(true)}>
<IconFlag className="size-3.5" />
Manage Flags
</Button>
<Button variant="ghost" size="icon" className="size-8" asChild>
<Link
to={`/user/profile/${searchResult.username}`}
title="View profile"
>
<IconArrowRight className="size-4" />
</Link>
</Button>
</div>
</div>
)}
</div>
</div>
{searchResult && (
<UserFeatureFlagsDialog
usernameOrToken={searchResult.username}
username={searchResult.username}
displayName={searchResult.display_name || searchResult.username}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
)}
</div>
);
}
@@ -1,315 +0,0 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Link } from "react-router-dom";
import { ModerationApi } from "@/api/ModerationApi";
import type { ImpersonationRequest } from "@/types";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import { useTableHeight } from "@/hooks/useTableHeight";
import {
IconAlertCircle,
IconKey,
IconRefresh,
IconLoader2,
IconArrowRight,
} from "@tabler/icons-react";
import { usePageTitle } from "@/hooks/usePageTitle";
export function Impersonation() {
usePageTitle("Impersonation Logs");
const [requests, setRequests] = useState<ImpersonationRequest[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const cancelledRef = useRef(false);
const observerRef = useRef<IntersectionObserver | null>(null);
const sentinelRef = useRef<HTMLTableRowElement | null>(null);
const loadData = async (cursor?: string | null, append = false) => {
if (!append) {
setIsLoading(true);
setError(null);
} else {
setIsLoadingMore(true);
}
try {
const modApi = new ModerationApi();
const resp = await modApi.ListAllImpersonationRequests(cursor);
if (cancelledRef.current) return;
if (resp.success && resp.data) {
setRequests((prev) =>
append
? [...prev, ...resp.data!.impersonation_requests]
: resp.data!.impersonation_requests,
);
setNextCursor(resp.data.next_cursor);
} else {
setError(resp.errorMessage || "Failed to load impersonation requests");
}
} catch (err: any) {
if (!cancelledRef.current)
setError(err.message || "Failed to load impersonation requests");
} finally {
if (!cancelledRef.current) {
setIsLoading(false);
setIsLoadingMore(false);
}
}
};
const handleRefresh = () => {
setRequests([]);
setNextCursor(null);
loadData();
};
useEffect(() => {
cancelledRef.current = false;
loadData();
return () => {
cancelledRef.current = true;
};
}, []);
const loadMore = useCallback(() => {
if (!isLoadingMore && nextCursor != null) {
loadData(nextCursor, true);
}
}, [nextCursor, isLoadingMore]);
useEffect(() => {
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMore();
},
{ threshold: 0.1 },
);
if (sentinelRef.current) {
observerRef.current.observe(sentinelRef.current);
}
return () => observerRef.current?.disconnect();
}, [loadMore]);
const { ref: tableRef, height: tableHeight } = useTableHeight();
const formatDate = (iso: string) =>
new Date(iso).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const statusBadge = (req: ImpersonationRequest) => {
if (req.is_redeemed) {
return (
<Badge
variant="secondary"
className="text-[10px] bg-emerald-500/10 text-emerald-400 border-transparent"
>
Redeemed
</Badge>
);
}
if (req.is_expired) {
return (
<Badge
variant="secondary"
className="text-[10px] bg-muted text-muted-foreground border-transparent"
>
Expired
</Badge>
);
}
return (
<Badge
variant="secondary"
className="text-[10px] bg-amber-500/10 text-amber-400 border-transparent"
>
Active
</Badge>
);
};
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<div className="flex-1 flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<IconKey className="size-6 text-muted-foreground" />
Impersonation Logs
</h1>
<p className="text-muted-foreground">
Audit trail of moderator impersonation requests (newest first)
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isLoading}
>
<IconRefresh
className={`size-4 ${isLoading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
{error && (
<Alert variant="destructive" className="max-w-xl">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex flex-col gap-4 flex-1 min-h-0">
<div ref={tableRef}>
{isLoading ? (
<Table containerClassName="rounded-xl border bg-card shadow-sm overflow-hidden">
<TableHeader>
<TableRow className="hover:bg-transparent bg-muted/30">
<TableHead className="text-xs">Moderator</TableHead>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs">Impersonated User</TableHead>
<TableHead className="text-xs">Created</TableHead>
<TableHead className="text-xs">Expires</TableHead>
<TableHead className="text-xs">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 10 }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-28" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-4" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-28" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-28" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-28" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-16 rounded-full" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : requests.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 bg-muted/20 border border-dashed rounded-xl">
<IconKey className="size-10 text-muted-foreground mb-4 opacity-20" />
<p className="text-muted-foreground">
No impersonation requests yet.
</p>
</div>
) : (
<Table
containerClassName="rounded-xl border bg-card shadow-sm min-h-[200px]"
containerStyle={{ maxHeight: tableHeight ?? "60vh" }}
>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow className="hover:bg-transparent bg-muted/30">
<TableHead className="text-xs">Moderator</TableHead>
<TableHead className="text-xs w-8"></TableHead>
<TableHead className="text-xs">Impersonated User</TableHead>
<TableHead className="text-xs">Created</TableHead>
<TableHead className="text-xs">Expires</TableHead>
<TableHead className="text-xs">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requests.map((req) => (
<TableRow
key={`${req.impersonator_user_token}-${req.impersonated_user_token}-${req.created_at}`}
className="group"
>
<TableCell className="text-sm font-medium">
<Link
to={`/user/profile/${req.impersonator_username}`}
className="hover:underline text-foreground"
>
@{req.impersonator_username}
</Link>
{req.impersonator_display_name && (
<span className="ml-2 text-xs text-muted-foreground">
{req.impersonator_display_name}
</span>
)}
</TableCell>
<TableCell className="text-muted-foreground/50">
<IconArrowRight className="size-3.5" />
</TableCell>
<TableCell className="text-sm font-medium">
<Link
to={`/user/profile/${req.impersonated_username}`}
className="hover:underline text-foreground"
>
@{req.impersonated_username}
</Link>
{req.impersonated_display_name && (
<span className="ml-2 text-xs text-muted-foreground">
{req.impersonated_display_name}
</span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground tabular-nums">
{formatDate(req.created_at)}
</TableCell>
<TableCell className="text-sm text-muted-foreground tabular-nums">
{formatDate(req.expires_at)}
</TableCell>
<TableCell className="text-xs">
{statusBadge(req)}
</TableCell>
</TableRow>
))}
{nextCursor != null && (
<TableRow ref={sentinelRef}>
<TableCell colSpan={6} className="text-center py-4">
{isLoadingMore ? (
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<IconLoader2 className="size-4 animate-spin" />
Loading more...
</div>
) : (
<span className="text-muted-foreground/50">
Scroll for more
</span>
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</div>
</div>
</div>
);
}
@@ -1,194 +0,0 @@
import { useState, useEffect, useRef } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { ModerationApi } from "@/api/ModerationApi";
import type { UserJob } from "@/types";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { JobsTable, getJobStatusBadgeProps } from "@/components/JobsTable";
import { useTableHeight } from "@/hooks/useTableHeight";
import {
IconArrowLeft,
IconAlertCircle,
IconBriefcase,
IconAlertTriangle,
IconSkull,
} from "@tabler/icons-react";
import { usePageTitle } from "@/hooks/usePageTitle";
export function JobHistory() {
const { username } = useParams<{ username: string }>();
usePageTitle(username ? `@${username} — Jobs` : "Jobs");
const navigate = useNavigate();
const [jobs, setJobs] = useState<UserJob[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const cancelledRef = useRef(false);
const loadData = async () => {
if (!username) return;
setIsLoading(true);
setError(null);
try {
const modApi = new ModerationApi();
const userResp = await modApi.UserLookup(username);
if (cancelledRef.current) return;
if (!userResp.success || !userResp.data?.maybe_user) {
setError(userResp.errorMessage || "User not found");
setIsLoading(false);
return;
}
const userToken = userResp.data.maybe_user.token;
const jobsResp = await modApi.ListUserJobs(userToken);
if (cancelledRef.current) return;
if (jobsResp.success && jobsResp.data) {
setJobs(jobsResp.data.jobs);
} else {
setError(jobsResp.errorMessage || "Failed to load jobs");
}
} catch (err: any) {
if (!cancelledRef.current)
setError(err.message || "Failed to load job data");
} finally {
if (!cancelledRef.current) setIsLoading(false);
}
};
useEffect(() => {
cancelledRef.current = false;
loadData();
return () => {
cancelledRef.current = true;
};
}, [username]);
const { ref: tableRef, height: tableHeight } = useTableHeight();
const totalJobs = jobs.length;
const failedJobs = jobs.filter(
(j) => getJobStatusBadgeProps(j.job_status).isFailed,
).length;
const needsRefund = jobs.filter((j) => {
const { isFailed } = getJobStatusBadgeProps(j.job_status);
return (
isFailed &&
j.credits_delta != null &&
j.credits_delta < 0 &&
!j.maybe_linked_refund_ledger_token
);
}).length;
return (
<div className="flex flex-col gap-8">
{/* Header */}
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/user/profile/${username}`)}
className="p-0! h-auto w-auto hover:bg-transparent! text-foreground/70 hover:text-foreground/50"
>
<IconArrowLeft className="size-6" />
</Button>
<div className="flex-1">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<IconBriefcase className="size-6 text-muted-foreground" />
Job History
</h1>
<p className="text-muted-foreground text-sm">
<Link to={`/user/profile/${username}`} className="hover:underline">
@{username}
</Link>
</p>
</div>
</div>
{error && (
<Alert variant="destructive" className="max-w-xl">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Summary Cards */}
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="rounded-xl border bg-card shadow-sm p-5 flex flex-col gap-3"
>
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-20" />
</div>
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="rounded-xl border bg-card shadow-sm p-5 flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<IconBriefcase className="size-5 text-foreground/70" />
<span className="text-sm font-medium text-foreground/70 uppercase tracking-wider">
Total Jobs
</span>
</div>
<span className="text-2xl font-bold tabular-nums">
{totalJobs.toLocaleString()}
</span>
</div>
<div className="rounded-xl border bg-card shadow-sm p-5 flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<IconSkull className="size-5 text-foreground/70" />
<span className="text-sm font-medium text-foreground/70 uppercase tracking-wider">
Failed Jobs
</span>
</div>
<span className="text-2xl font-bold tabular-nums">
{failedJobs.toLocaleString()}
</span>
</div>
<div className="rounded-xl border bg-card shadow-sm p-5 flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<IconAlertTriangle className="size-5 text-foreground/70" />
<span className="text-sm font-medium text-foreground/70 uppercase tracking-wider">
Needs Refund
</span>
</div>
<span className="text-2xl font-bold tabular-nums text-amber-400">
{needsRefund.toLocaleString()}
</span>
</div>
</div>
)}
{/* Jobs Table */}
<div className="flex flex-col gap-4 flex-1 min-h-0">
<h3 className="text-xl font-bold flex items-center gap-2">
<IconBriefcase className="size-5 text-muted-foreground" />
All Jobs
{!isLoading && (
<span className="text-sm font-normal text-muted-foreground ml-1">
({totalJobs})
</span>
)}
</h3>
<div ref={tableRef}>
<JobsTable
jobs={jobs}
isLoading={isLoading}
skeletonRows={8}
maxHeight={tableHeight}
/>
</div>
</div>
</div>
);
}
@@ -1,650 +0,0 @@
import { useState, useEffect } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { ModerationApi } from "@/api/ModerationApi";
import type { ModerationJob } from "@/types";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { getJobStatusBadgeProps } from "@/components/JobsTable";
import { usePageTitle } from "@/hooks/usePageTitle";
import { humanize } from "@/lib/utils";
import { RawTag } from "@/components/RawTag";
import {
IconAlertCircle,
IconArrowLeft,
IconBriefcase,
IconCheck,
IconCopy,
IconExternalLink,
IconInfoCircle,
IconBug,
IconUser,
IconServer,
IconCpu,
IconSparkles,
IconAlertTriangle,
IconLink,
IconRefresh,
} from "@tabler/icons-react";
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
});
}
interface CopyableTokenProps {
value: string;
copiedId?: string | null;
onCopy: (value: string, id: string) => void;
id: string;
className?: string;
to?: string;
}
function CopyableToken({
value,
copiedId,
onCopy,
id,
className = "",
to,
}: CopyableTokenProps) {
const isCopied = copiedId === id;
return (
<div className={`flex items-center gap-1 ${className}`}>
{to ? (
<Link
to={to}
className="flex-1 min-w-0 inline-flex items-center gap-1 font-mono text-xs hover:underline text-foreground truncate"
>
<span className="truncate">{value}</span>
<IconExternalLink className="size-3 shrink-0 opacity-60" />
</Link>
) : (
<span className="flex-1 min-w-0 font-mono text-xs text-foreground truncate">
{value}
</span>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => onCopy(value, id)}
title="Copy to clipboard"
>
{isCopied ? (
<IconCheck className="size-3 text-emerald-400" />
) : (
<IconCopy className="size-3" />
)}
</Button>
</div>
);
}
interface FieldProps {
label: string;
children: React.ReactNode;
}
function Field({ label, children }: FieldProps) {
return (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{label}
</span>
{children}
</div>
);
}
function NoneValue() {
return <span className="text-sm text-muted-foreground/40 italic">None</span>;
}
function StringValue({ value }: { value: string }) {
return <span className="text-sm wrap-break-word">{value}</span>;
}
// Renders snake_case backend enum values as humanized text with the raw value
// shown alongside in a small monospace pill. Use for fields whose values are
// shaped like "complete_success" / "image_generation" / "fal_queue".
function EnumValue({ value }: { value: string }) {
return (
<div className="inline-flex flex-wrap items-center gap-1.5">
<span className="text-sm">{humanize(value)}</span>
<RawTag value={value} />
</div>
);
}
interface SectionProps {
title: string;
icon: React.ComponentType<{ className?: string }>;
children: React.ReactNode;
className?: string;
}
function Section({
title,
icon: Icon,
children,
className = "",
}: SectionProps) {
return (
<div
className={`rounded-xl border bg-card text-card-foreground shadow-sm flex flex-col p-5 ${className}`}
>
<h4 className="text-sm font-semibold mb-4 text-foreground/80 uppercase tracking-wider pb-3 border-b border-border/50 flex items-center gap-2 h-[45px]">
<Icon className="size-4" />
{title}
</h4>
<div className="flex flex-col gap-5">{children}</div>
</div>
);
}
export function JobInfo() {
const { jobToken } = useParams<{ jobToken: string }>();
usePageTitle(jobToken ? `Job ${jobToken.slice(0, 8)}…` : "Job");
const navigate = useNavigate();
const [job, setJob] = useState<ModerationJob | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const copy = (value: string, id: string) => {
navigator.clipboard.writeText(value).then(() => {
setCopiedId(id);
setTimeout(() => setCopiedId((c) => (c === id ? null : c)), 2000);
});
};
const loadJob = async () => {
if (!jobToken) return;
setIsLoading(true);
setError(null);
try {
const api = new ModerationApi();
const resp = await api.GetJobByToken(jobToken);
if (resp.success && resp.data?.maybe_job) {
setJob(resp.data.maybe_job);
} else {
setError(resp.errorMessage || "Job not found");
}
} catch (err: any) {
setError(err.message || "Failed to load job");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadJob();
}, [jobToken]);
const badge = job ? getJobStatusBadgeProps(job.status) : null;
return (
<div className="flex flex-col gap-8 pb-10">
{/* Header */}
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(-1)}
className="p-0! h-auto w-auto hover:bg-transparent! text-foreground/70 hover:text-foreground/50"
>
<IconArrowLeft className="size-6" />
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<IconBriefcase className="size-6 text-muted-foreground" />
Job Info
</h1>
{jobToken && (
<p className="text-muted-foreground text-sm font-mono mt-1 truncate">
{jobToken}
</p>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={loadJob}
disabled={isLoading}
>
<IconRefresh className="size-4" />
Refresh
</Button>
<Button
variant="outline"
size="sm"
onClick={() => copy(window.location.href, "job_link")}
>
{copiedId === "job_link" ? (
<IconCheck className="size-4 text-emerald-400" />
) : (
<IconCopy className="size-4" />
)}
Copy Link
</Button>
</div>
</div>
{error && !isLoading && (
<Alert variant="destructive" className="max-w-xl">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="rounded-xl border bg-card shadow-sm flex flex-col p-5 gap-4"
>
<Skeleton className="h-4 w-24" />
{Array.from({ length: 3 }).map((_, j) => (
<div key={j} className="flex flex-col gap-1.5">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-full" />
</div>
))}
</div>
))}
</div>
)}
{job && badge && (
<>
{/* Quick Summary Banner */}
<div className="relative rounded-2xl border bg-card text-card-foreground shadow-sm overflow-hidden">
<div className="p-6 flex flex-col md:flex-row gap-6 items-start md:items-center">
<div className="flex flex-col gap-2 flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<div className="inline-flex items-center gap-1.5">
<Badge variant={badge.variant} className={badge.className}>
{humanize(job.status)}
</Badge>
<RawTag value={job.status} />
</div>
<div className="inline-flex items-center gap-1.5">
<Badge variant="secondary" className="text-xs">
{humanize(job.inference_category)}
</Badge>
<RawTag value={job.inference_category} />
</div>
{job.maybe_job_type && (
<div className="inline-flex items-center gap-1.5">
<Badge variant="outline" className="text-xs">
{humanize(job.maybe_job_type)}
</Badge>
<RawTag value={job.maybe_job_type} />
</div>
)}
{job.attempt_count > 1 && (
<Badge
variant="outline"
className="text-xs text-amber-400 border-amber-500/30"
>
Attempt {job.attempt_count}
</Badge>
)}
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-1 mt-1 text-xs text-muted-foreground">
<span>Created {formatDateTime(job.created_at)}</span>
<span>Updated {formatDateTime(job.updated_at)}</span>
</div>
</div>
<div className="md:ml-auto flex flex-wrap gap-2">
{job.maybe_debug_log_event_token && (
<Button variant="default" size="sm" asChild>
<Link
to={`/moderation/debug-logs/${encodeURIComponent(
job.maybe_debug_log_event_token,
)}`}
>
<IconBug className="size-4" />
View Debug Logs
</Link>
</Button>
)}
</div>
</div>
</div>
{/* Failure Banner (if failed) */}
{(job.failure_reason || job.internal_debugging_failure_reason) && (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 p-5 flex flex-col gap-3">
<h4 className="text-sm font-semibold text-destructive uppercase tracking-wider flex items-center gap-2">
<IconAlertTriangle className="size-4" />
Failure Details
</h4>
{job.failure_reason && (
<Field label="Failure Reason">
<p className="text-sm wrap-break-word text-destructive/90">
{job.failure_reason}
</p>
</Field>
)}
{job.frontend_failure_category && (
<Field label="Frontend Category">
<div className="inline-flex items-center gap-1.5">
<Badge variant="outline" className="w-max">
{humanize(job.frontend_failure_category)}
</Badge>
<RawTag value={job.frontend_failure_category} />
</div>
</Field>
)}
{job.internal_debugging_failure_reason && (
<Field label="Internal Debug Reason">
<pre className="text-xs font-mono whitespace-pre-wrap wrap-break-word bg-muted/40 p-3 rounded-lg text-foreground/90">
{job.internal_debugging_failure_reason}
</pre>
</Field>
)}
</div>
)}
{/* Detail Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
{/* Identity */}
<Section title="Identity" icon={IconInfoCircle}>
<Field label="Job Token">
<CopyableToken
value={job.token}
copiedId={copiedId}
onCopy={copy}
id="job_token"
/>
</Field>
<Field label="Idempotency Token">
<CopyableToken
value={job.uuid_idempotency_token}
copiedId={copiedId}
onCopy={copy}
id="idem"
/>
</Field>
<Field label="Status">
<div className="inline-flex items-center gap-1.5">
<span className="text-sm font-medium">
{humanize(job.status)}
</span>
<RawTag value={job.status} />
</div>
</Field>
<Field label="Attempt Count">
<span className="text-sm tabular-nums">
{job.attempt_count}
</span>
</Field>
</Section>
{/* Creator */}
<Section title="Creator" icon={IconUser}>
<Field label="User">
{job.maybe_creator_user_token ? (
<CopyableToken
value={job.maybe_creator_user_token}
copiedId={copiedId}
onCopy={copy}
id="creator_user"
/>
) : (
<NoneValue />
)}
</Field>
<Field label="Anonymous Visitor">
{job.maybe_creator_anonymous_visitor_token ? (
<CopyableToken
value={job.maybe_creator_anonymous_visitor_token}
copiedId={copiedId}
onCopy={copy}
id="creator_anon"
/>
) : (
<NoneValue />
)}
</Field>
<Field label="IP Address">
<span className="text-sm font-mono">
{job.creator_ip_address || "—"}
</span>
</Field>
<Field label="Visibility">
<div className="inline-flex items-center gap-1.5">
<Badge variant="outline" className="w-max">
{humanize(job.creator_set_visibility)}
</Badge>
<RawTag value={job.creator_set_visibility} />
</div>
</Field>
</Section>
{/* Worker / Routing */}
<Section title="Worker & Routing" icon={IconServer}>
<Field label="Assigned Cluster">
{job.assigned_cluster ? (
<StringValue value={job.assigned_cluster} />
) : (
<NoneValue />
)}
</Field>
<Field label="Assigned Worker">
{job.assigned_worker ? (
<StringValue value={job.assigned_worker} />
) : (
<NoneValue />
)}
</Field>
<Field label="Routing Tag">
{job.maybe_routing_tag ? (
<EnumValue value={job.maybe_routing_tag} />
) : (
<NoneValue />
)}
</Field>
<Field label="Job Type">
{job.maybe_job_type ? (
<EnumValue value={job.maybe_job_type} />
) : (
<NoneValue />
)}
</Field>
</Section>
{/* Model & Inference */}
<Section title="Model & Inference" icon={IconCpu}>
<Field label="Inference Category">
<div className="inline-flex items-center gap-1.5">
<Badge variant="secondary" className="w-max">
{humanize(job.inference_category)}
</Badge>
<RawTag value={job.inference_category} />
</div>
</Field>
<Field label="Product Category">
{job.maybe_product_category ? (
<EnumValue value={job.maybe_product_category} />
) : (
<NoneValue />
)}
</Field>
<Field label="Model Type">
{job.maybe_model_type ? (
<EnumValue value={job.maybe_model_type} />
) : (
<NoneValue />
)}
</Field>
<Field label="Model Token">
{job.maybe_model_token ? (
<CopyableToken
value={job.maybe_model_token}
copiedId={copiedId}
onCopy={copy}
id="model_token"
/>
) : (
<NoneValue />
)}
</Field>
<Field label="Prompt Token">
{job.maybe_prompt_token ? (
<CopyableToken
value={job.maybe_prompt_token}
copiedId={copiedId}
onCopy={copy}
id="prompt_token"
/>
) : (
<NoneValue />
)}
</Field>
</Section>
{/* Third Party */}
<Section title="Third Party" icon={IconSparkles}>
<Field label="Provider">
{job.maybe_external_third_party ? (
<div className="inline-flex items-center gap-1.5">
<Badge variant="outline" className="w-max">
{humanize(job.maybe_external_third_party)}
</Badge>
<RawTag value={job.maybe_external_third_party} />
</div>
) : (
<NoneValue />
)}
</Field>
<Field label="External ID">
{job.maybe_external_third_party_id ? (
<CopyableToken
value={job.maybe_external_third_party_id}
copiedId={copiedId}
onCopy={copy}
id="external_id"
/>
) : (
<NoneValue />
)}
</Field>
</Section>
{/* Result */}
<Section title="Result" icon={IconLink}>
<Field label="Entity Token">
{job.on_success_result_entity_token ? (
<CopyableToken
value={job.on_success_result_entity_token}
copiedId={copiedId}
onCopy={copy}
id="result_entity"
/>
) : (
<NoneValue />
)}
</Field>
<Field label="Entity Type">
{job.on_success_result_entity_type ? (
<EnumValue value={job.on_success_result_entity_type} />
) : (
<NoneValue />
)}
</Field>
<Field label="Batch Token">
{job.on_success_result_batch_token ? (
<CopyableToken
value={job.on_success_result_batch_token}
copiedId={copiedId}
onCopy={copy}
id="result_batch"
/>
) : (
<NoneValue />
)}
</Field>
<Field label="Download URL">
{job.maybe_download_url ? (
<a
href={job.maybe_download_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm inline-flex items-center gap-1 hover:underline break-all"
>
<IconExternalLink className="size-3 shrink-0" />
<span className="truncate">{job.maybe_download_url}</span>
</a>
) : (
<NoneValue />
)}
</Field>
<Field label="Wallet Ledger Entry">
{job.maybe_wallet_ledger_entry_token ? (
<CopyableToken
value={job.maybe_wallet_ledger_entry_token}
copiedId={copiedId}
onCopy={copy}
id="ledger_entry"
/>
) : (
<NoneValue />
)}
</Field>
</Section>
</div>
{/* Inference args (long) */}
{job.maybe_inference_args && (
<div className="rounded-xl border bg-card shadow-sm p-5 flex flex-col gap-3">
<h4 className="text-sm font-semibold text-foreground/80 uppercase tracking-wider flex items-center gap-2">
<IconCpu className="size-4" />
Inference Arguments
</h4>
<PrettyJsonBlock raw={job.maybe_inference_args} />
</div>
)}
</>
)}
</div>
);
}
function PrettyJsonBlock({ raw }: { raw: string }) {
const trimmed = raw.trim();
let formatted = raw;
let isJson = false;
try {
const parsed = JSON.parse(trimmed);
formatted = JSON.stringify(parsed, null, 2);
isJson = true;
} catch {
formatted = raw;
}
return (
<pre
className={`text-xs font-mono whitespace-pre-wrap wrap-break-word bg-muted/40 p-4 rounded-lg max-h-[400px] overflow-auto ${
isJson ? "text-foreground/90" : "text-foreground/80"
}`}
>
{formatted}
</pre>
);
}
@@ -1,18 +0,0 @@
import { IconBriefcase } from "@tabler/icons-react";
import { TokenSearchForm } from "@/components/TokenSearchForm";
import { usePageTitle } from "@/hooks/usePageTitle";
export function JobTokenSearch() {
usePageTitle("Search Job by Token");
return (
<TokenSearchForm
title="Search Job by Token"
description="Look up a single inference job by its token to see its full state and any debug logs."
inputLabel="Job Token"
placeholder="Paste a job token..."
buttonLabel="Open"
icon={IconBriefcase}
buildHref={(token) => `/moderation/job/${encodeURIComponent(token)}`}
/>
);
}
@@ -1,133 +0,0 @@
import { useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useAuth } from "@/context/AuthContext";
import { usePageTitle } from "@/hooks/usePageTitle";
import { UsersApi } from "@/api/UsersApi";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { IconAlertCircle, IconLogin } from "@tabler/icons-react";
import { Spinner } from "@/components/ui/spinner";
import artcraftIcon from "@/assets/artcraft-icon.svg";
export function LoginPage() {
usePageTitle("Login");
const [usernameOrEmail, setUsernameOrEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const { checkSession } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const redirectTo = searchParams.get("redirect") || "/";
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
const api = new UsersApi();
const response = await api.Login({ usernameOrEmail, password });
if (response.success) {
const isModerator = await checkSession(false);
if (!isModerator) {
setError("You do not have admin privileges to access.");
return;
}
navigate(redirectTo, { replace: true });
} else {
setError(response.errorMessage || "Login failed");
}
} catch (err: any) {
setError(err.message || "An error occurred");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="flex min-h-screen w-full items-center justify-center p-4">
<div className="flex w-full max-w-sm flex-col gap-6">
<Card>
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center">
<img
src={artcraftIcon}
alt="Artcraft Logo"
className="h-full w-full object-contain"
draggable={false}
/>
</div>
<CardTitle className="text-2xl font-bold tracking-tight font-outfit">
Admin Login
</CardTitle>
<CardDescription className="text-zinc-500 dark:text-zinc-400">
Login to your ArtCraft admin account
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="grid gap-4">
{error && (
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>
{error.charAt(0).toUpperCase() + error.slice(1)}
</AlertDescription>
</Alert>
)}
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="text"
placeholder="Email or username"
required
value={usernameOrEmail}
onChange={(e) => setUsernameOrEmail(e.target.value)}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
</div>
<Input
id="password"
type="password"
required
value={password}
placeholder="••••••"
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<Button
type="submit"
className="w-full mt-4"
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Spinner />
Logging in...
</>
) : (
<>
<IconLogin className="size-4" />
Login
</>
)}
</Button>
</form>
</CardContent>
</Card>
</div>
</div>
);
}
@@ -1,25 +0,0 @@
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { IconArrowLeft } from "@tabler/icons-react";
import { usePageTitle } from "@/hooks/usePageTitle";
export function NotFoundPage() {
usePageTitle("Not Found");
return (
<div className="flex min-h-screen w-full flex-col items-center justify-center gap-4 p-4">
<p className="text-8xl font-bold font-outfit tracking-tight text-muted-foreground/30">
404
</p>
<h1 className="text-xl font-semibold tracking-tight">Page not found</h1>
<p className="text-sm text-muted-foreground">
The page you're looking for doesn't exist or has been moved.
</p>
<Button asChild variant="ghost" className="mt-2">
<Link to="/">
<IconArrowLeft className="size-4" />
Back
</Link>
</Button>
</div>
);
}
@@ -1,358 +0,0 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Link } from "react-router-dom";
import {
ModerationApi,
type UserReferralListItem,
} from "@/api/ModerationApi";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import { useTableHeight } from "@/hooks/useTableHeight";
import {
IconAlertCircle,
IconShare,
IconRefresh,
IconLoader2,
} from "@tabler/icons-react";
import { usePageTitle } from "@/hooks/usePageTitle";
function formatDate(iso: string) {
return new Date(iso).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function truncateUrl(url: string, max = 40) {
return url.length > max ? url.slice(0, max - 1) + "…" : url;
}
export function ReferralsAnalytics() {
usePageTitle("Referrals");
const [referrals, setReferrals] = useState<UserReferralListItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const cancelledRef = useRef(false);
const observerRef = useRef<IntersectionObserver | null>(null);
const sentinelRef = useRef<HTMLTableRowElement | null>(null);
const loadData = async (cursor?: string | null, append = false) => {
if (!append) {
setIsLoading(true);
setError(null);
} else {
setIsLoadingMore(true);
}
try {
const modApi = new ModerationApi();
const resp = await modApi.ListGlobalUserReferrals(cursor);
if (cancelledRef.current) return;
if (resp.success && resp.data) {
setReferrals((prev) =>
append ? [...prev, ...resp.data!.referrals] : resp.data!.referrals,
);
setNextCursor(resp.data.next_cursor);
} else {
setError(resp.errorMessage || "Failed to load referrals");
}
} catch (err: any) {
if (!cancelledRef.current)
setError(err.message || "Failed to load referrals");
} finally {
if (!cancelledRef.current) {
setIsLoading(false);
setIsLoadingMore(false);
}
}
};
const handleRefresh = () => {
setReferrals([]);
setNextCursor(null);
loadData();
};
useEffect(() => {
cancelledRef.current = false;
loadData();
return () => {
cancelledRef.current = true;
};
}, []);
const loadMore = useCallback(() => {
if (!isLoadingMore && nextCursor != null) {
loadData(nextCursor, true);
}
}, [nextCursor, isLoadingMore]);
useEffect(() => {
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMore();
},
{ threshold: 0.1 },
);
if (sentinelRef.current) {
observerRef.current.observe(sentinelRef.current);
}
return () => observerRef.current?.disconnect();
}, [loadMore]);
const { ref: tableRef, height: tableHeight } = useTableHeight();
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<div className="flex-1 flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<IconShare className="size-6 text-muted-foreground" />
Referrals
</h1>
<p className="text-muted-foreground">
All referral signups, newest first
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isLoading}
>
<IconRefresh
className={`size-4 ${isLoading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
{error && (
<Alert variant="destructive" className="max-w-xl">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex flex-col gap-4 flex-1 min-h-0">
<h3 className="text-xl font-bold flex items-center gap-2">
<IconShare className="size-5 text-muted-foreground" />
All Referrals
{!isLoading && (
<span className="text-sm font-normal text-muted-foreground ml-1">
({referrals.length}
{nextCursor != null ? "+" : ""})
</span>
)}
</h3>
<div ref={tableRef}>
{isLoading ? (
<Table containerClassName="rounded-xl border bg-card shadow-sm overflow-hidden">
<TableHeader>
<TableRow className="hover:bg-transparent bg-muted/30">
<TableHead className="text-xs">When</TableHead>
<TableHead className="text-xs">Invited</TableHead>
<TableHead className="text-xs">Email</TableHead>
<TableHead className="text-xs">Referrer</TableHead>
<TableHead className="text-xs">Via</TableHead>
<TableHead className="text-xs">Landing URL</TableHead>
<TableHead className="text-xs">Referrer URL</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 10 }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-28" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-36" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-48" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-48" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : referrals.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 bg-muted/20 border border-dashed rounded-xl">
<IconShare className="size-10 text-muted-foreground mb-4 opacity-20" />
<p className="text-muted-foreground">No referrals yet.</p>
</div>
) : (
<Table
containerClassName="rounded-xl border bg-card shadow-sm min-h-[200px]"
containerStyle={{ maxHeight: tableHeight ?? "60vh" }}
>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow className="hover:bg-transparent bg-muted/30">
<TableHead className="text-xs">When</TableHead>
<TableHead className="text-xs">Invited</TableHead>
<TableHead className="text-xs">Email</TableHead>
<TableHead className="text-xs">Referrer</TableHead>
<TableHead className="text-xs">Via</TableHead>
<TableHead className="text-xs">Landing URL</TableHead>
<TableHead className="text-xs">Referrer URL</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{referrals.map((ref, i) => (
<ReferralRow
key={`${ref.invited_user.token}-${ref.created_at}-${i}`}
referral={ref}
/>
))}
{nextCursor != null && (
<TableRow ref={sentinelRef}>
<TableCell colSpan={7} className="text-center py-4">
{isLoadingMore ? (
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<IconLoader2 className="size-4 animate-spin" />
Loading more...
</div>
) : (
<span className="text-muted-foreground/50">
Scroll for more
</span>
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</div>
</div>
</div>
);
}
function ReferralRow({ referral }: { referral: UserReferralListItem }) {
const { invited_user, referrer_user, maybe_referral_code_token, maybe_landing_url, maybe_referral_url, created_at } = referral;
const urlTitle =
maybe_landing_url || maybe_referral_url || undefined;
return (
<TableRow className="group">
<TableCell className="text-sm text-muted-foreground tabular-nums whitespace-nowrap">
{formatDate(created_at)}
</TableCell>
<TableCell className="text-sm">
<Link
to={`/user/profile/${invited_user.username}`}
className="hover:underline text-foreground font-medium"
>
@{invited_user.username}
</Link>
{invited_user.display_name && (
<span className="ml-2 text-xs text-muted-foreground">
{invited_user.display_name}
</span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{invited_user.email_address || (
<span className="text-muted-foreground/30">&mdash;</span>
)}
</TableCell>
<TableCell className="text-sm">
<Link
to={`/user/profile/${referrer_user.username}/referrals`}
className="hover:underline text-foreground font-medium"
>
@{referrer_user.username}
</Link>
{referrer_user.display_name && (
<span className="ml-2 text-xs text-muted-foreground">
{referrer_user.display_name}
</span>
)}
</TableCell>
<TableCell className="text-xs">
{maybe_referral_code_token ? (
<Badge
variant="secondary"
className="text-[10px] font-mono bg-indigo-500/10 text-indigo-300 border-transparent"
title={urlTitle}
>
code
</Badge>
) : maybe_landing_url || maybe_referral_url ? (
<Badge
variant="outline"
className="text-[10px] text-muted-foreground"
title={urlTitle}
>
link
</Badge>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
)}
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground whitespace-nowrap">
{maybe_landing_url ? (
<a
href={maybe_landing_url}
target="_blank"
rel="noopener noreferrer"
className="hover:underline hover:text-foreground"
title={maybe_landing_url}
>
{truncateUrl(maybe_landing_url)}
</a>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
)}
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground whitespace-nowrap">
{maybe_referral_url ? (
<a
href={maybe_referral_url}
target="_blank"
rel="noopener noreferrer"
className="hover:underline hover:text-foreground"
title={maybe_referral_url}
>
{truncateUrl(maybe_referral_url)}
</a>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
)}
</TableCell>
</TableRow>
);
}
@@ -1,250 +0,0 @@
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 { Alert, AlertDescription } from "@/components/ui/alert";
import { Spinner } from "@/components/ui/spinner";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
IconAlertCircle,
IconBellRinging,
IconCheck,
IconSend,
} from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { usePageTitle } from "@/hooks/usePageTitle";
type Urgency = "high" | "medium" | "low";
const URGENCY_META: Record<
Urgency,
{ label: string; description: string; className: string }
> = {
high: {
label: "High",
description: "Pages oncall immediately. Use for production incidents.",
className: "text-destructive",
},
medium: {
label: "Medium",
description: "Notifies during working hours.",
className: "text-amber-400",
},
low: {
label: "Low",
description: "Logged for review, no immediate page.",
className: "text-emerald-400",
},
};
export function SendPager() {
usePageTitle("Send Pager");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [urgency, setUrgency] = useState<Urgency>("low");
const [confirmOpen, setConfirmOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [result, setResult] = useState<
{ success: true } | { success: false; message: string } | null
>(null);
const effectiveTitle = title.trim() || "Test Moderation Alert";
const effectiveDescription =
description.trim() || "This is a test moderation alert.";
const handleConfirm = async () => {
setIsSubmitting(true);
setResult(null);
const modApi = new ModerationApi();
const resp = await modApi.SendAlert({
title: title.trim() || null,
description: description.trim() || null,
urgency,
});
setIsSubmitting(false);
setConfirmOpen(false);
if (resp.success) {
setResult({ success: true });
setTitle("");
setDescription("");
} else {
setResult({
success: false,
message: resp.errorMessage || "Failed to send alert",
});
}
};
const urgencyMeta = URGENCY_META[urgency];
return (
<div className="flex flex-col gap-6 max-w-2xl">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<IconBellRinging className="size-6 text-muted-foreground" />
Send Pager
</h1>
<p className="text-muted-foreground">
Dispatch a test alert through the pager system.
</p>
</div>
<div className="rounded-xl border bg-card shadow-sm p-6 flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<Label htmlFor="alert-title" className="text-sm font-medium">
Title
</Label>
<Input
id="alert-title"
placeholder="Test Moderation Alert"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={120}
/>
<p className="text-xs text-muted-foreground">
Leave blank to use the default title.
</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="alert-description" className="text-sm font-medium">
Description
</Label>
<textarea
id="alert-description"
rows={4}
placeholder="This is a test moderation alert."
value={description}
onChange={(e) => setDescription(e.target.value)}
className={cn(
"w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none resize-none",
"focus-visible:border-primary transition-colors",
"placeholder:text-muted-foreground dark:bg-input/30",
)}
/>
<p className="text-xs text-muted-foreground">
Leave blank to use the default description.
</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="alert-urgency" className="text-sm font-medium">
Urgency
</Label>
<Select
value={urgency}
onValueChange={(v) => setUrgency(v as Urgency)}
>
<SelectTrigger id="alert-urgency" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(URGENCY_META) as Urgency[]).map((u) => (
<SelectItem key={u} value={u}>
<span className={URGENCY_META[u].className}>
{URGENCY_META[u].label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className={cn("text-xs transition-colors", urgencyMeta.className)}>
{urgencyMeta.description}
</p>
</div>
{result?.success === true && (
<Alert className="border-emerald-500/40 bg-emerald-500/5">
<IconCheck className="size-4 text-emerald-400" />
<AlertDescription className="text-emerald-300">
Alert dispatched successfully.
</AlertDescription>
</Alert>
)}
{result?.success === false && (
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>{result.message}</AlertDescription>
</Alert>
)}
<div className="flex justify-end">
<Button
size="sm"
variant={urgency === "high" ? "destructive" : "default"}
onClick={() => setConfirmOpen(true)}
disabled={isSubmitting}
>
<IconSend className="size-3.5" />
Send Alert
</Button>
</div>
</div>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent overlayClassName="bg-black/70">
<AlertDialogHeader>
<AlertDialogTitle>Send {urgencyMeta.label} alert?</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="flex flex-col gap-2">
<span>
You are about to dispatch a{" "}
<span className={cn("font-semibold", urgencyMeta.className)}>
{urgencyMeta.label.toLowerCase()} urgency
</span>{" "}
page through the alerting system.
</span>
<div className="rounded-md border bg-muted/30 p-3 flex flex-col gap-1 text-sm">
<span className="font-semibold text-foreground">
{effectiveTitle}
</span>
<span className="text-muted-foreground">
{effectiveDescription}
</span>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSubmitting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
variant={urgency === "high" ? "destructive" : "default"}
disabled={isSubmitting}
onClick={(e) => {
e.preventDefault();
handleConfirm();
}}
>
{isSubmitting ? (
<Spinner className="size-3.5" />
) : (
<IconSend className="size-3.5" />
)}
{isSubmitting ? "Sending..." : "Send"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -1,311 +0,0 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Link } from "react-router-dom";
import { ModerationApi } from "@/api/ModerationApi";
import type { StaffAuditLog } from "@/types";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import { useTableHeight } from "@/hooks/useTableHeight";
import {
IconAlertCircle,
IconClipboardList,
IconRefresh,
IconLoader2,
} from "@tabler/icons-react";
import { usePageTitle } from "@/hooks/usePageTitle";
export function StaffAuditLogs() {
usePageTitle("Staff Audit Logs");
const [logs, setLogs] = useState<StaffAuditLog[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const cancelledRef = useRef(false);
const observerRef = useRef<IntersectionObserver | null>(null);
const sentinelRef = useRef<HTMLTableRowElement | null>(null);
const loadData = async (cursor?: string | null, append = false) => {
if (!append) {
setIsLoading(true);
setError(null);
} else {
setIsLoadingMore(true);
}
try {
const modApi = new ModerationApi();
const resp = await modApi.ListStaffAuditLogs(cursor);
if (cancelledRef.current) return;
if (resp.success && resp.data) {
setLogs((prev) =>
append ? [...prev, ...resp.data!.audit_logs] : resp.data!.audit_logs,
);
setNextCursor(resp.data.next_cursor);
} else {
setError(resp.errorMessage || "Failed to load audit logs");
}
} catch (err: any) {
if (!cancelledRef.current)
setError(err.message || "Failed to load audit logs");
} finally {
if (!cancelledRef.current) {
setIsLoading(false);
setIsLoadingMore(false);
}
}
};
const handleRefresh = () => {
setLogs([]);
setNextCursor(null);
loadData();
};
useEffect(() => {
cancelledRef.current = false;
loadData();
return () => {
cancelledRef.current = true;
};
}, []);
const loadMore = useCallback(() => {
if (!isLoadingMore && nextCursor != null) {
loadData(nextCursor, true);
}
}, [nextCursor, isLoadingMore]);
useEffect(() => {
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMore();
},
{ threshold: 0.1 },
);
if (sentinelRef.current) {
observerRef.current.observe(sentinelRef.current);
}
return () => observerRef.current?.disconnect();
}, [loadMore]);
const { ref: tableRef, height: tableHeight } = useTableHeight();
const formatDate = (iso: string) =>
new Date(iso).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<div className="flex-1 flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<IconClipboardList className="size-6 text-muted-foreground" />
Staff Audit Logs
</h1>
<p className="text-muted-foreground">
Every moderator action, newest first
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isLoading}
>
<IconRefresh
className={`size-4 ${isLoading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
{error && (
<Alert variant="destructive" className="max-w-xl">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex flex-col gap-4 flex-1 min-h-0">
<div ref={tableRef}>
{isLoading ? (
<Table containerClassName="rounded-xl border bg-card shadow-sm overflow-hidden">
<TableHeader>
<TableRow className="hover:bg-transparent bg-muted/30">
<TableHead className="text-xs">When</TableHead>
<TableHead className="text-xs">Staff</TableHead>
<TableHead className="text-xs">Action</TableHead>
<TableHead className="text-xs">Target</TableHead>
<TableHead className="text-xs">Entity</TableHead>
<TableHead className="text-xs">IP</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 10 }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-28" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-32 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-28" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : logs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 bg-muted/20 border border-dashed rounded-xl">
<IconClipboardList className="size-10 text-muted-foreground mb-4 opacity-20" />
<p className="text-muted-foreground">No audit logs yet.</p>
</div>
) : (
<Table
containerClassName="rounded-xl border bg-card shadow-sm min-h-[200px]"
containerStyle={{ maxHeight: tableHeight ?? "60vh" }}
>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow className="hover:bg-transparent bg-muted/30">
<TableHead className="text-xs">When</TableHead>
<TableHead className="text-xs">Staff</TableHead>
<TableHead className="text-xs">Action</TableHead>
<TableHead className="text-xs">Target</TableHead>
<TableHead className="text-xs">Entity</TableHead>
<TableHead className="text-xs">IP</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.map((log) => (
<TableRow key={log.token} className="group">
<TableCell className="text-sm text-muted-foreground tabular-nums whitespace-nowrap">
{formatDate(log.created_at)}
</TableCell>
<TableCell className="text-sm font-medium">
{log.staff_username ? (
<Link
to={`/user/profile/${log.staff_username}`}
className="hover:underline text-foreground"
>
@{log.staff_username}
</Link>
) : (
<span className="text-muted-foreground/30">
&mdash;
</span>
)}
{log.staff_display_name && (
<span className="ml-2 text-xs text-muted-foreground">
{log.staff_display_name}
</span>
)}
</TableCell>
<TableCell className="text-xs">
<Badge
variant="secondary"
className="text-[10px] font-mono bg-indigo-500/10 text-indigo-300 border-transparent"
>
{log.audit_action}
</Badge>
</TableCell>
<TableCell className="text-sm">
{log.maybe_target_username ? (
<Link
to={`/user/profile/${log.maybe_target_username}`}
className="hover:underline text-foreground font-medium"
>
@{log.maybe_target_username}
</Link>
) : (
<span className="text-muted-foreground/30">
&mdash;
</span>
)}
{log.maybe_target_display_name && (
<span className="ml-2 text-xs text-muted-foreground">
{log.maybe_target_display_name}
</span>
)}
</TableCell>
<TableCell className="text-xs">
{log.maybe_entity_type ? (
<div className="flex flex-col gap-0.5">
<Badge
variant="outline"
className="text-[10px] text-muted-foreground w-fit"
>
{log.maybe_entity_type}
</Badge>
{log.maybe_entity_token && (
<span className="font-mono text-[10px] text-muted-foreground/60 truncate max-w-[140px]">
{log.maybe_entity_token}
</span>
)}
</div>
) : (
<span className="text-muted-foreground/30">
&mdash;
</span>
)}
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground">
{log.staff_ip_address}
</TableCell>
</TableRow>
))}
{nextCursor != null && (
<TableRow ref={sentinelRef}>
<TableCell colSpan={6} className="text-center py-4">
{isLoadingMore ? (
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<IconLoader2 className="size-4 animate-spin" />
Loading more...
</div>
) : (
<span className="text-muted-foreground/50">
Scroll for more
</span>
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</div>
</div>
</div>
);
}
@@ -1,214 +0,0 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { ModerationApi } from "@/api/ModerationApi";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { getIdenticonUrl } from "@/lib/identicon";
import { formatNamespace, stripeCustomerDashboardUrl } from "@/lib/stripe";
import { usePageTitle } from "@/hooks/usePageTitle";
import {
IconAlertCircle,
IconBrandStripe,
IconSearch,
IconArrowRight,
IconExternalLink,
} from "@tabler/icons-react";
interface StripeUser {
display_name: string;
email_address: string;
maybe_stripe_subscription_id?: string | null;
subscription_namespace: string;
token: string;
username: string;
}
export function StripeLookup() {
usePageTitle("Stripe Lookup");
const [query, setQuery] = useState("");
const [isSearching, setIsSearching] = useState(false);
const [results, setResults] = useState<StripeUser[] | null>(null);
const [error, setError] = useState<string | null>(null);
const handleLookup = async (e: React.FormEvent) => {
e.preventDefault();
const q = query.trim();
if (!q) return;
setIsSearching(true);
setError(null);
setResults(null);
try {
const api = new ModerationApi();
const resp = await api.LookupByStripeCustomerId(q);
if (resp.success && resp.data) {
setResults(resp.data.users);
} else {
setError(resp.errorMessage || "Lookup failed");
}
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setIsSearching(false);
}
};
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<IconBrandStripe className="size-6 text-muted-foreground" />
Stripe Customer Lookup
</h1>
<p className="text-muted-foreground">
Find users by their Stripe Customer ID. Useful when a user's email is
unlinked due to billing or signup issues.
</p>
</div>
<Card className="max-w-2xl">
<CardHeader className="pb-0">
<form onSubmit={handleLookup} className="flex gap-2">
<Input
type="text"
placeholder="cus_XXXXXXXXXX"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="flex-1 font-mono text-sm"
/>
<Button type="submit" disabled={isSearching || !query.trim()}>
<IconSearch className="size-4" />
{isSearching ? "Searching..." : "Lookup"}
</Button>
</form>
{query.trim() && (
<a
href={stripeCustomerDashboardUrl(query.trim())}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mt-2"
>
<IconExternalLink className="size-3.5" />
View in Stripe Dashboard
</a>
)}
</CardHeader>
<Separator />
<CardContent>
{error && (
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{isSearching && (
<div className="flex flex-col gap-3">
{Array.from({ length: 2 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-3 p-3 rounded-lg border"
>
<Skeleton className="size-10 rounded-lg shrink-0" />
<div className="flex-1 flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<Skeleton className="h-3 w-48" />
</div>
<Skeleton className="h-8 w-24 rounded-md shrink-0" />
</div>
))}
</div>
)}
{results !== null && !isSearching && (
<>
{results.length === 0 ? (
<div className="text-center py-12 text-muted-foreground text-sm">
No users found for this Stripe Customer ID.
</div>
) : (
<div className="flex flex-col gap-2">
<p className="text-xs text-muted-foreground mb-1">
{results.length} user{results.length !== 1 && "s"} found
</p>
{results.map((user) => (
<div
key={user.token}
className="flex items-center gap-3 p-3 rounded-lg border bg-secondary/30 transition-colors"
>
<Avatar className="size-10 rounded-lg shrink-0">
<AvatarFallback className="rounded-lg p-0">
<img
src={getIdenticonUrl(user.username)}
alt={user.username}
className="h-full w-full"
/>
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold truncate">
{user.display_name || user.username}
</span>
<Badge
variant="secondary"
className="text-[10px] shrink-0"
>
{formatNamespace(user.subscription_namespace)}
</Badge>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground truncate">
@{user.username}
</span>
{user.email_address && (
<>
<span className="text-muted-foreground/30 text-xs">
&middot;
</span>
<span className="text-xs text-muted-foreground truncate">
{user.email_address}
</span>
</>
)}
</div>
</div>
<Button
variant="secondary"
size="sm"
asChild
className="shrink-0"
>
<Link to={`/user/profile/${user.username}`}>
View Profile
<IconArrowRight className="size-3.5" />
</Link>
</Button>
</div>
))}
</div>
)}
</>
)}
{results === null && !isSearching && !error && (
<div className="text-center py-12 text-muted-foreground text-sm">
Enter a Stripe Customer ID above to find linked users.
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -1,314 +0,0 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Link } from "react-router-dom";
import { ModerationApi } from "@/api/ModerationApi";
import type { SubscriberUser } from "@/types";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import { useTableHeight } from "@/hooks/useTableHeight";
import {
IconAlertCircle,
IconCreditCard,
IconExternalLink,
IconRefresh,
IconLoader2,
} from "@tabler/icons-react";
import { usePageTitle } from "@/hooks/usePageTitle";
function getSubscriptionStatusBadge(status: string | null) {
if (!status) return { className: "text-muted-foreground", label: "Unknown" };
const s = status.toLowerCase();
if (s === "active")
return { className: "bg-emerald-500/10 text-emerald-400 border-transparent", label: "Active" };
if (s === "trialing")
return { className: "bg-blue-500/10 text-blue-400 border-transparent", label: "Trialing" };
if (s === "canceled" || s === "cancelled")
return { className: "bg-destructive/10 text-destructive border-transparent", label: "Canceled" };
if (s === "past_due")
return { className: "bg-amber-500/10 text-amber-400 border-transparent", label: "Past Due" };
if (s === "unpaid")
return { className: "bg-orange-500/10 text-orange-400 border-transparent", label: "Unpaid" };
return { className: "text-muted-foreground", label: status };
}
export function SubscriberSignups() {
usePageTitle("Subscriber Signups");
const [users, setUsers] = useState<SubscriberUser[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [nextCursor, setNextCursor] = useState<number | null>(null);
const cancelledRef = useRef(false);
const observerRef = useRef<IntersectionObserver | null>(null);
const sentinelRef = useRef<HTMLTableRowElement | null>(null);
const loadData = async (cursor?: number | null, append = false) => {
if (!append) {
setIsLoading(true);
setError(null);
} else {
setIsLoadingMore(true);
}
try {
const modApi = new ModerationApi();
const resp = await modApi.ListSubscribersBySignupDate(cursor);
if (cancelledRef.current) return;
if (resp.success && resp.data) {
setUsers((prev) => (append ? [...prev, ...resp.data!.users] : resp.data!.users));
setNextCursor(resp.data.next_cursor);
} else {
setError(resp.errorMessage || "Failed to load subscribers");
}
} catch (err: any) {
if (!cancelledRef.current)
setError(err.message || "Failed to load subscribers");
} finally {
if (!cancelledRef.current) {
setIsLoading(false);
setIsLoadingMore(false);
}
}
};
const handleRefresh = () => {
setUsers([]);
setNextCursor(null);
loadData();
};
useEffect(() => {
cancelledRef.current = false;
loadData();
return () => {
cancelledRef.current = true;
};
}, []);
const loadMore = useCallback(() => {
if (!isLoadingMore && nextCursor != null) {
loadData(nextCursor, true);
}
}, [nextCursor, isLoadingMore]);
useEffect(() => {
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMore();
},
{ threshold: 0.1 },
);
if (sentinelRef.current) {
observerRef.current.observe(sentinelRef.current);
}
return () => observerRef.current?.disconnect();
}, [loadMore]);
const { ref: tableRef, height: tableHeight } = useTableHeight();
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<div className="flex-1 flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<IconCreditCard className="size-6 text-muted-foreground" />
Subscriber Signups
</h1>
<p className="text-muted-foreground">
Subscribing users by signup date (newest first)
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isLoading}
>
<IconRefresh className={`size-4 ${isLoading ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
{error && (
<Alert variant="destructive" className="max-w-xl">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex flex-col gap-4 flex-1 min-h-0">
<h3 className="text-xl font-bold flex items-center gap-2">
<IconCreditCard className="size-5 text-muted-foreground" />
Subscribers
{!isLoading && (
<span className="text-sm font-normal text-muted-foreground ml-1">
({users.length}{nextCursor != null ? "+" : ""})
</span>
)}
</h3>
<div ref={tableRef}>
{isLoading ? (
<Table containerClassName="rounded-xl border bg-card shadow-sm overflow-hidden">
<TableHeader>
<TableRow className="hover:bg-transparent bg-muted/30">
<TableHead className="text-xs">Username</TableHead>
<TableHead className="text-xs">Email</TableHead>
<TableHead className="text-xs">Signed Up</TableHead>
<TableHead className="text-xs">Product</TableHead>
<TableHead className="text-xs">Interval</TableHead>
<TableHead className="text-xs">Status</TableHead>
<TableHead className="text-xs">Paid</TableHead>
<TableHead className="text-xs"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 10 }).map((_, i) => (
<TableRow key={i}>
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
<TableCell><Skeleton className="h-4 w-36" /></TableCell>
<TableCell><Skeleton className="h-4 w-28" /></TableCell>
<TableCell><Skeleton className="h-4 w-20" /></TableCell>
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
<TableCell><Skeleton className="h-5 w-20 rounded-full" /></TableCell>
<TableCell><Skeleton className="h-4 w-12" /></TableCell>
<TableCell><Skeleton className="h-6 w-20 rounded" /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : users.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 bg-muted/20 border border-dashed rounded-xl">
<IconCreditCard className="size-10 text-muted-foreground mb-4 opacity-20" />
<p className="text-muted-foreground">No subscribers found.</p>
</div>
) : (
<Table
containerClassName="rounded-xl border bg-card shadow-sm min-h-[200px]"
containerStyle={{ maxHeight: tableHeight ?? "60vh" }}
>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow className="hover:bg-transparent bg-muted/30">
<TableHead className="text-xs">Username</TableHead>
<TableHead className="text-xs">Email</TableHead>
<TableHead className="text-xs">Signed Up</TableHead>
<TableHead className="text-xs">Product</TableHead>
<TableHead className="text-xs">Interval</TableHead>
<TableHead className="text-xs">Status</TableHead>
<TableHead className="text-xs">Paid</TableHead>
<TableHead className="text-xs"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => {
const statusBadge = getSubscriptionStatusBadge(
user.maybe_stripe_subscription_status,
);
return (
<TableRow key={user.id} className="group">
<TableCell className="text-sm font-medium">
<Link
to={`/user/profile/${user.username}`}
className="hover:underline text-foreground"
>
@{user.username}
</Link>
</TableCell>
<TableCell className="text-sm">
<span className="text-muted-foreground">{user.email_address}</span>
{!user.email_confirmed && (
<Badge variant="outline" className="ml-2 text-[10px] text-amber-400 border-amber-400/30">
Unconfirmed
</Badge>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground tabular-nums">
{new Date(user.created_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</TableCell>
<TableCell className="text-xs">
{user.subscription_product_slug ? (
<Badge variant="secondary" className="text-[10px]">
{user.subscription_product_slug}
</Badge>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground capitalize">
{user.maybe_stripe_recurring_interval || (
<span className="text-muted-foreground/30">&mdash;</span>
)}
</TableCell>
<TableCell className="text-xs">
<Badge variant="secondary" className={statusBadge.className + " text-[10px]"}>
{statusBadge.label}
</Badge>
</TableCell>
<TableCell className="text-xs">
{user.maybe_stripe_invoice_is_paid === true ? (
<Badge variant="secondary" className="text-[10px] bg-emerald-500/10 text-emerald-400 border-transparent">
Paid
</Badge>
) : user.maybe_stripe_invoice_is_paid === false ? (
<Badge variant="secondary" className="text-[10px] bg-destructive/10 text-destructive border-transparent">
Unpaid
</Badge>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
)}
</TableCell>
<TableCell className="text-xs">
<Button variant="ghost" size="sm" asChild className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">
<a href={`/user/profile/${user.username}`} target="_blank" rel="noopener noreferrer">
<IconExternalLink className="size-3.5" />
View Profile
</a>
</Button>
</TableCell>
</TableRow>
);
})}
{nextCursor != null && (
<TableRow ref={sentinelRef}>
<TableCell colSpan={8} className="text-center py-4">
{isLoadingMore ? (
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<IconLoader2 className="size-4 animate-spin" />
Loading more...
</div>
) : (
<span className="text-muted-foreground/50">Scroll for more</span>
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</div>
</div>
</div>
);
}
@@ -1,204 +0,0 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useParams, Link } from "react-router-dom";
import { MediaApi, type MediaInfo } from "@/api/MediaApi";
import { ModerationApi } from "@/api/ModerationApi";
import { MediaCard } from "@/components/MediaCard";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Spinner } from "@/components/ui/spinner";
import {
IconArrowLeft,
IconAlertCircle,
IconPhoto,
} from "@tabler/icons-react";
import { usePageTitle } from "@/hooks/usePageTitle";
const PAGE_SIZE = 32;
export function UserCreations() {
const { username } = useParams<{ username: string }>();
usePageTitle(username ? `@${username} — Creations` : "Creations");
const [displayName, setDisplayName] = useState<string | null>(null);
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
const [items, setItems] = useState<MediaInfo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
const cancelledRef = useRef(false);
// Refs track loading state and next page index without causing observer recreation
const isFetchingRef = useRef(false);
const hasMoreRef = useRef(true);
const nextPageRef = useRef(0);
const loadMore = useCallback(async () => {
if (!username || isFetchingRef.current || !hasMoreRef.current) return;
isFetchingRef.current = true;
setIsLoadingMore(true);
const pageIndex = nextPageRef.current;
try {
const mediaApi = new MediaApi();
const resp = await mediaApi.ListUserMediaFiles(username, {
page_index: pageIndex,
page_size: PAGE_SIZE,
filter_media_classes: ["image", "video", "dimensional"],
});
if (cancelledRef.current) return;
if (resp.success && resp.data) {
setItems((prev) => [...prev, ...resp.data!]);
nextPageRef.current = pageIndex + 1;
if (resp.pagination) {
const more = pageIndex + 1 < resp.pagination.total_pages;
hasMoreRef.current = more;
setHasMore(more);
}
} else {
setError(resp.errorMessage || "Failed to load creations");
}
} finally {
isFetchingRef.current = false;
if (!cancelledRef.current) setIsLoadingMore(false);
}
}, [username]);
useEffect(() => {
cancelledRef.current = false;
nextPageRef.current = 0;
hasMoreRef.current = true;
async function init() {
if (!username) return;
const modApi = new ModerationApi();
const mediaApi = new MediaApi();
const [userResp, mediaResp] = await Promise.all([
modApi.UserLookup(username),
mediaApi.ListUserMediaFiles(username, {
page_index: 0,
page_size: PAGE_SIZE,
filter_media_classes: ["image", "video", "dimensional"],
}),
]);
if (cancelledRef.current) return;
if (userResp.success && userResp.data?.maybe_user) {
setDisplayName(userResp.data.maybe_user.display_name || null);
setGravatarHash(userResp.data.maybe_user.email_gravatar_hash || null);
}
if (mediaResp.success && mediaResp.data) {
setItems(mediaResp.data);
nextPageRef.current = 1;
if (mediaResp.pagination) {
const more = 1 < mediaResp.pagination.total_pages;
hasMoreRef.current = more;
setHasMore(more);
}
} else {
setError(mediaResp.errorMessage || "Failed to load creations");
}
setIsLoading(false);
}
init();
return () => {
cancelledRef.current = true;
};
}, [username]);
// Set up observer once after initial load — uses refs so it never needs rebuilding
useEffect(() => {
if (isLoading || !sentinelRef.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) loadMore();
},
{ threshold: 0.1 },
);
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [isLoading, loadMore]);
return (
<div className="flex flex-col gap-6 pb-10">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
className="p-0! h-auto w-auto hover:bg-transparent! text-foreground/70 hover:text-foreground/50"
asChild
>
<Link to={`/user/profile/${username}`}>
<IconArrowLeft className="size-6" />
</Link>
</Button>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<IconPhoto className="size-6 text-muted-foreground" />
Creations
{username && (
<span className="text-muted-foreground font-normal text-2xl">
@{username}
</span>
)}
</h1>
</div>
{error && (
<Alert variant="destructive">
<IconAlertCircle className="size-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{isLoading ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-8 gap-5">
{Array.from({ length: PAGE_SIZE }).map((_, i) => (
<Skeleton key={i} className="aspect-square rounded-xl" />
))}
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 bg-muted/20 border border-dashed rounded-xl">
<IconPhoto className="size-10 text-muted-foreground mb-4 opacity-20" />
<p className="text-muted-foreground">This user has no creations yet.</p>
</div>
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-8 gap-5">
{items.map((item) => (
<MediaCard
key={item.token}
item={item}
showCreator
creator={{
username: username!,
display_name: displayName ?? undefined,
gravatar_hash: gravatarHash ?? undefined,
}}
/>
))}
</div>
<div ref={sentinelRef} className="flex justify-center py-6">
{isLoadingMore ? (
<Spinner className="size-6 opacity-50" />
) : !hasMore ? (
<p className="text-muted-foreground text-sm">All creations loaded</p>
) : null}
</div>
</>
)}
</div>
);
}
@@ -1,348 +0,0 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import {
ModerationApi,
type ModeratorUserLookupResponse,
} from "@/api/ModerationApi";
import type { UserEmailChange, UserEmailChangeUserSummary } from "@/types";
import { Button } from "@/components/ui/button";
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 {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import { UserLookupPanel } from "@/components/UserLookupPanel";
import { ChangeUserEmailDialog } from "@/components/ChangeUserEmailDialog";
import { getIdenticonUrl } from "@/lib/identicon";
import { useTableHeight } from "@/hooks/useTableHeight";
import { usePageTitle } from "@/hooks/usePageTitle";
import {
IconMail,
IconAlertCircle,
IconArrowNarrowRight,
IconRefresh,
IconLoader2,
IconPencil,
} from "@tabler/icons-react";
function gravatarUrl(hash?: string): string | undefined {
return hash ? `https://gravatar.com/avatar/${hash}?s=48&d=404` : undefined;
}
function UserSummaryCell({ user }: { user: UserEmailChangeUserSummary }) {
return (
<Link
to={`/user/profile/${user.username}`}
className="flex items-center gap-2 group/user w-fit"
>
<Avatar className="size-6 rounded-md shrink-0">
<AvatarImage src={gravatarUrl(user.gravatar_hash)} draggable={false} />
<AvatarFallback className="rounded-md p-0">
<img
src={getIdenticonUrl(user.username)}
alt={user.username}
className="size-full"
draggable={false}
/>
</AvatarFallback>
</Avatar>
<div className="flex flex-col min-w-0 leading-tight">
<span className="text-xs font-medium truncate group-hover/user:underline">
{user.display_name || user.username}
</span>
<span className="text-[10px] text-muted-foreground truncate">
@{user.username}
</span>
</div>
</Link>
);
}
export function UserEmailChanges() {
usePageTitle("Email Changes");
const [user, setUser] = useState<ModeratorUserLookupResponse | null>(null);
const [currentEmail, setCurrentEmail] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [changes, setChanges] = useState<UserEmailChange[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const cancelledRef = useRef(false);
const observerRef = useRef<IntersectionObserver | null>(null);
const sentinelRef = useRef<HTMLTableRowElement | null>(null);
const loadHistory = async (
username: string,
cursor?: string | null,
append = false,
) => {
if (!append) {
setIsLoading(true);
setHistoryError(null);
} else {
setIsLoadingMore(true);
}
const modApi = new ModerationApi();
const resp = await modApi.ListUserEmailChanges(username, cursor);
if (cancelledRef.current) return;
if (resp.success && resp.data) {
setChanges((prev) =>
append ? [...prev, ...resp.data!.changes] : resp.data!.changes,
);
setNextCursor(resp.data.next_cursor);
} else {
setHistoryError(resp.errorMessage || "Failed to load email changes");
}
setIsLoading(false);
setIsLoadingMore(false);
};
// Guard against state updates after unmount.
useEffect(() => {
cancelledRef.current = false;
return () => {
cancelledRef.current = true;
};
}, []);
const loadMore = useCallback(() => {
if (user && !isLoadingMore && nextCursor != null) {
loadHistory(user.username, nextCursor, true);
}
}, [user, nextCursor, isLoadingMore]);
useEffect(() => {
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMore();
},
{ threshold: 0.1 },
);
if (sentinelRef.current) {
observerRef.current.observe(sentinelRef.current);
}
return () => observerRef.current?.disconnect();
}, [loadMore]);
const handleSelect = (next: ModeratorUserLookupResponse | null) => {
setUser(next);
setCurrentEmail(next?.email_address ?? null);
setChanges([]);
setNextCursor(null);
if (next) loadHistory(next.username);
};
const handleChangeSuccess = (newEmail: string) => {
setCurrentEmail(newEmail);
setUser((prev) => (prev ? { ...prev, email_address: newEmail } : prev));
toast.success("Email address changed");
if (user) loadHistory(user.username);
};
const { ref: tableRef, height: tableHeight } = useTableHeight();
const formatDate = (iso: string) =>
new Date(iso).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return (
<div className="flex flex-col gap-6 pb-10">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<IconMail className="size-6 text-muted-foreground" />
Email Changes
</h1>
<p className="text-muted-foreground">
Look up a user to change their email address and review the full
history of email changes.
</p>
</div>
<UserLookupPanel
user={user}
displayEmail={currentEmail}
onSelect={handleSelect}
actions={() => (
<Button size="sm" onClick={() => setDialogOpen(true)}>
<IconPencil className="size-3.5" />
Change Email
</Button>
)}
/>
{user && (
<div className="flex flex-col gap-4 flex-1 min-h-0">
<div className="flex items-center gap-4">
<h4 className="flex-1 text-sm font-semibold text-foreground/80 uppercase tracking-wider flex items-center gap-2">
<IconMail className="size-4" />
Email Change History
</h4>
<Button
variant="outline"
size="sm"
onClick={() => loadHistory(user.username)}
disabled={isLoading}
>
<IconRefresh
className={`size-4 ${isLoading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
{historyError && (
<Alert variant="destructive" className="max-w-xl">
<IconAlertCircle className="size-4" />
<AlertDescription>{historyError}</AlertDescription>
</Alert>
)}
<div ref={tableRef}>
{isLoading ? (
<Table containerClassName="rounded-xl border bg-card shadow-sm overflow-hidden">
<TableHeader>
<TableRow className="hover:bg-transparent bg-muted/30">
<TableHead className="text-xs">When</TableHead>
<TableHead className="text-xs">Old Email</TableHead>
<TableHead className="text-xs">New Email</TableHead>
<TableHead className="text-xs">Changed By</TableHead>
<TableHead className="text-xs">IP</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 6 }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-28" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-40" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-40" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : changes.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 bg-muted/20 border border-dashed rounded-xl">
<IconMail className="size-10 text-muted-foreground mb-4 opacity-20" />
<p className="text-muted-foreground">
No recorded email changes for this user.
</p>
</div>
) : (
<Table
containerClassName="rounded-xl border bg-card shadow-sm min-h-[200px]"
containerStyle={{ maxHeight: tableHeight ?? "60vh" }}
>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow className="hover:bg-transparent bg-muted/30">
<TableHead className="text-xs">When</TableHead>
<TableHead className="text-xs">Old Email</TableHead>
<TableHead className="text-xs">New Email</TableHead>
<TableHead className="text-xs">Changed By</TableHead>
<TableHead className="text-xs">IP</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{changes.map((change) => (
<TableRow key={change.id} className="group">
<TableCell className="text-sm text-muted-foreground tabular-nums whitespace-nowrap">
{formatDate(change.created_at)}
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground break-all max-w-[220px]">
{change.old_email}
</TableCell>
<TableCell className="text-xs font-mono break-all max-w-[220px]">
<span className="inline-flex items-center gap-1.5">
<IconArrowNarrowRight className="size-3.5 text-muted-foreground/50 shrink-0" />
{change.new_email}
</span>
</TableCell>
<TableCell className="text-xs">
{change.maybe_changed_by_user ? (
<UserSummaryCell user={change.maybe_changed_by_user} />
) : (
<Badge
variant="outline"
className="text-[10px] text-muted-foreground"
>
Self / system
</Badge>
)}
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground whitespace-nowrap">
{change.ip_address}
</TableCell>
</TableRow>
))}
{nextCursor != null && (
<TableRow ref={sentinelRef}>
<TableCell colSpan={5} className="text-center py-4">
{isLoadingMore ? (
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<IconLoader2 className="size-4 animate-spin" />
Loading more...
</div>
) : (
<span className="text-muted-foreground/50">
Scroll for more
</span>
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</div>
</div>
)}
{user && (
<ChangeUserEmailDialog
userToken={user.token}
username={user.username}
displayName={user.display_name || user.username}
currentEmail={currentEmail ?? undefined}
open={dialogOpen}
onOpenChange={setDialogOpen}
onSuccess={handleChangeSuccess}
/>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More