Add dashboard UI source and track it in the repository.

Expose the Vite/React website source under dashboard/ (with node_modules still ignored) so the full dashboard can be built and maintained from this repo.
This commit is contained in:
Char committed 2026-07-26 13:09:19 +01:00
1 parent e97e281bda
commit b82f69783d
117 files changed
+18615 -3

No files matched your search

+1 -3
View File
@@ -1,7 +1,5 @@
node_modules
# Dashboard UI source at repo root only (do not ignore public/dashboard/)
/dashboard/
dashboard/node_modules
# Local git hooks + secret-stripping scripts (run npm run git:setup)
scripts/
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SupportBot Dashboard</title>
<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=Poppins:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4977
View File
File diff suppressed because it is too large. Load diff
+51
View File
@@ -0,0 +1,51 @@
{
"name": "supportbot-dashboard",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@hugeicons/core-free-icons": "^4.1.4",
"@hugeicons/react": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"emoji-picker-element": "^1.29.1",
"lucide-react": "^1.16.0",
"postcss": "^8.5.14",
"react": "^19.1.0",
"react-colorful": "^5.7.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^25.8.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.5.0",
"typescript": "^5.8.3",
"vite": "^6.3.5"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+119
View File
@@ -0,0 +1,119 @@
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import Layout from "./components/Layout";
import { useAuth } from "./context/AuthContext";
import { useSetup } from "./context/SetupContext";
import Configs from "./pages/Configs";
import Login from "./pages/Login";
import Setup from "./pages/Setup";
import { HomeRedirect } from "./components/home-redirect";
import { RequirePermission } from "./components/require-permission";
import Settings from "./pages/Settings";
import SystemDetails from "./pages/SystemDetails";
import Logs from "./pages/Logs";
import Transcripts from "./pages/Transcripts";
import Users from "./pages/Users";
import Addons from "./pages/Addons";
function Protected({ children }: { children: React.ReactNode }) {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return (
<div className="flex min-h-svh items-center justify-center bg-background text-sm text-muted-foreground">
Loading…
</div>
);
}
if (!isAuthenticated) return <Navigate to="/login" replace />;
return <>{children}</>;
}
export default function App() {
const { complete, loading } = useSetup();
const location = useLocation();
if (loading) {
return (
<div className="flex min-h-svh items-center justify-center bg-background text-sm text-muted-foreground">
Loading…
</div>
);
}
if (!complete) {
return (
<Routes>
<Route path="/setup" element={<Setup />} />
<Route path="*" element={<Navigate to="/setup" replace />} />
</Routes>
);
}
if (location.pathname === "/setup") {
return <Navigate to="/login" replace />;
}
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
element={
<Protected>
<Layout />
</Protected>
}
>
<Route index element={<HomeRedirect />} />
<Route
path="system"
element={
<RequirePermission permission="overview">
<SystemDetails />
</RequirePermission>
}
/>
<Route
path="settings"
element={
<RequirePermission permission="settings.view">
<Settings />
</RequirePermission>
}
/>
<Route path="configs" element={<Configs />} />
<Route
path="logs"
element={
<RequirePermission permission="logs">
<Logs />
</RequirePermission>
}
/>
<Route
path="transcripts"
element={
<RequirePermission permission="transcripts">
<Transcripts />
</RequirePermission>
}
/>
<Route
path="users"
element={
<RequirePermission permission="users.view">
<Users />
</RequirePermission>
}
/>
<Route
path="addons"
element={
<RequirePermission permission="settings.view">
<Addons />
</RequirePermission>
}
/>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
+581
View File
@@ -0,0 +1,581 @@
import type {
DashboardPermissions,
DashboardRole,
DashboardUser,
StoredDashboardUser,
} from "@/lib/permissions";
export type { DashboardUser, DashboardPermissions, StoredDashboardUser, DashboardRole };
const REQUEST_TIMEOUT_MS = 25_000;
async function request<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const headers = new Headers(options.headers);
if (!headers.has("Content-Type") && options.body) {
headers.set("Content-Type", "application/json");
}
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
let res: Response;
try {
res = await fetch(path, {
...options,
headers,
credentials: "include",
signal: controller.signal,
});
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
throw new Error(
"Request timed out. The API may still be starting or the bot is restarting — try again.",
);
}
throw new Error(
err instanceof Error
? err.message
: "Could not reach the API. Is the bot process running?",
);
} finally {
window.clearTimeout(timeout);
}
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(body.error || `Request failed (${res.status})`);
}
return body as T;
}
export type LogType = "Output" | "Warn" | "Error";
export interface LogEntry {
type: LogType;
timestamp: string | null;
message: string;
}
export interface LogsPayload {
entries: LogEntry[];
cursor: Record<string, number>;
}
export interface BotRestartResult {
success: boolean;
error?: string;
commands?: number;
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
restartRequired?: boolean;
reloaded?: string[];
botRestart?: BotRestartResult;
}
export interface CatalogAddon {
id: string;
name: string;
description: string;
repositoryUrl: string;
jsFiles: string[];
configFiles: string[];
fileCount: number;
installed?: boolean;
}
export interface UpdateCheckResult {
current: string;
latest: string;
updateAvailable: boolean;
repository: string;
branch: string;
zipUrl: string;
}
export type NotificationType = "bot" | "update" | "info" | "error";
export interface DashboardNotification {
id: string;
type: NotificationType;
title: string;
message: string;
href: string | null;
createdAt: number;
read: boolean;
}
export interface NotificationsPayload {
items: DashboardNotification[];
unreadCount: number;
}
export type ModuleSetupStatus = "todo" | "progress" | "done";
export interface ModuleStatusInfo {
status: ModuleSetupStatus;
subtitle: string;
passed?: number;
total?: number;
}
export interface GuildSummary {
id: string;
name: string;
memberCount: number | null;
icon: string | null;
}
export type GuildHealthStatus =
| "ok"
| "missing_config"
| "not_in_guild"
| "extra_guilds"
| "offline";
export interface GuildHealth {
status: GuildHealthStatus;
configuredGuildId: string | null;
message: string | null;
configuredGuild: GuildSummary | null;
extraGuilds: GuildSummary[];
}
export type DashboardAlertSeverity = "error" | "warning" | "info";
export interface DashboardAlertPayload {
id: string;
severity: DashboardAlertSeverity;
category: string;
title: string;
message: string;
action?: { label: string; href: string };
meta?: Record<string, unknown>;
}
export interface GuildEmojiResource {
id: string;
name: string;
animated: boolean;
url: string | null;
}
export interface GuildResources {
guildId: string;
guildName: string;
roles: { id: string; name: string; color: string | null }[];
channels: { id: string; name: string; type: number }[];
categories: { id: string; name: string }[];
emojis: GuildEmojiResource[];
}
export interface BotStats {
bot: {
username: string;
id: string;
avatar: string;
inviteUrl?: string;
version: string;
ping: number;
uptime: number;
};
servers: number;
users: number;
tickets: { total: number; open: number; closed: number };
modules?: Record<string, ModuleStatusInfo>;
hosting: {
ram_used: string;
ram_total: string;
ram_percent: number;
cpu_load: string;
uptime: string;
};
}
export function maskConfigSecrets(content: string, filename: string): string {
if (filename !== "supportbot") return content;
return content.replace(
/^(\s*Token:\s*)["'][^"']*["']/m,
'$1"BOT_TOKEN"',
);
}
export const api = {
me: () => request<ApiResponse<DashboardUser>>("/api/auth/me"),
logout: () =>
request<ApiResponse<unknown>>("/api/auth/logout", { method: "POST" }),
health: () =>
request<
ApiResponse<{
dashboard: boolean;
botReady: boolean;
oauthEnabled?: boolean;
}>
>("/api/health"),
stats: () => request<ApiResponse<BotStats>>("/api/stats"),
getAlerts: () => request<ApiResponse<DashboardAlertPayload[]>>("/api/alerts"),
getGuildStatus: () =>
request<ApiResponse<GuildHealth>>("/api/guild/status"),
getGuildResources: (refresh = false) =>
request<ApiResponse<GuildResources>>(
`/api/guild/resources${refresh ? "?refresh=1" : ""}`,
),
leaveGuild: (guildId: string) =>
request<ApiResponse<GuildHealth>>(`/api/guild/${guildId}/leave`, {
method: "POST",
}),
listConfigs: () =>
[
"supportbot",
"ticket-panel",
"commands",
"messages",
"supportbot-ai",
] as const,
getConfigRaw: (file: string) =>
request<ApiResponse<string>>(`/api/configs/${file}`),
saveConfigRaw: (filename: string, content: string) =>
request<ApiResponse<unknown>>("/api/configs/raw", {
method: "PUT",
body: JSON.stringify({ filename, content }),
}),
getConfigJson: (file: string) =>
request<ApiResponse<Record<string, unknown>>>(`/api/configs/json/${file}`),
updateConfigFields: (updates: Record<string, unknown>) =>
request<ApiResponse<unknown>>("/api/configs/update-fields/multi", {
method: "POST",
body: JSON.stringify(updates),
}),
checkForUpdates: () =>
request<ApiResponse<UpdateCheckResult>>("/api/system/update-check"),
runUpdate: (version: string) =>
request<ApiResponse<unknown>>("/api/system/update", {
method: "POST",
body: JSON.stringify({ version }),
}),
reloadConfigs: (file?: string) =>
request<ApiResponse<unknown>>("/api/system/reload-configs", {
method: "POST",
body: JSON.stringify(file ? { file } : {}),
}),
getLogsMeta: () =>
request<
ApiResponse<{
types: LogType[];
files: { type: LogType; date: string; file: string | null; size: number }[];
}>
>("/api/system/logs/meta"),
getLogs: (params: {
types?: LogType[];
cursor?: Record<string, number>;
tail?: number;
}) => {
const q = new URLSearchParams();
if (params.types?.length) q.set("types", params.types.join(","));
if (params.cursor && Object.keys(params.cursor).length > 0) {
q.set("cursor", JSON.stringify(params.cursor));
}
if (params.tail != null) q.set("tail", String(params.tail));
const qs = q.toString();
return request<ApiResponse<LogsPayload>>(
`/api/system/logs${qs ? `?${qs}` : ""}`,
);
},
listTranscripts: () =>
request<
ApiResponse<
{
id: string;
filename: string;
createdAt: string;
size?: number;
ticketName?: string | null;
}[]
>
>("/api/system/transcripts"),
getTranscript: (id: string) =>
request<ApiResponse<string>>(`/api/system/transcripts/${id}`),
getTranscriptSettings: () =>
request<
ApiResponse<{
autoDelete: { enabled: boolean; afterDays: number };
publicAccess: { enabled: boolean };
publicBasePath?: string;
}>
>("/api/system/transcript-settings"),
updateTranscriptSettings: (body: {
autoDelete?: { enabled?: boolean; afterDays?: number };
publicAccess?: { enabled?: boolean };
}) =>
request<
ApiResponse<{
autoDelete: { enabled: boolean; afterDays: number };
publicAccess: { enabled: boolean };
publicBasePath?: string;
}>
>("/api/system/transcript-settings", {
method: "PUT",
body: JSON.stringify(body),
}),
deleteTranscript: (id: string) =>
request<ApiResponse<unknown>>(`/api/system/transcripts/${encodeURIComponent(id)}`, {
method: "DELETE",
}),
purgeTranscripts: () =>
request<
ApiResponse<{ deleted: number; skipped: boolean }>
>("/api/system/transcripts/purge", { method: "POST" }),
getBranding: () =>
request<
ApiResponse<{
title: string;
pageTitle: string;
hasFavicon: boolean;
faviconUrl: string | null;
updatedAt: number | null;
}>
>("/api/branding"),
updateBranding: (body: { title?: string; pageTitle?: string }) =>
request<
ApiResponse<{
title: string;
pageTitle: string;
hasFavicon: boolean;
faviconUrl: string | null;
updatedAt: number | null;
}>
>("/api/branding", {
method: "PUT",
body: JSON.stringify(body),
}),
uploadBrandingFavicon: (dataUrl: string) =>
request<
ApiResponse<{
title: string;
pageTitle: string;
hasFavicon: boolean;
faviconUrl: string | null;
updatedAt: number | null;
}>
>("/api/branding/favicon", {
method: "POST",
body: JSON.stringify({ dataUrl }),
}),
removeBrandingFavicon: () =>
request<
ApiResponse<{
title: string;
pageTitle: string;
hasFavicon: boolean;
faviconUrl: string | null;
updatedAt: number | null;
}>
>("/api/branding/favicon", { method: "DELETE" }),
resetBranding: () =>
request<
ApiResponse<{
title: string;
pageTitle: string;
hasFavicon: boolean;
faviconUrl: string | null;
updatedAt: number | null;
}>
>("/api/branding/reset", { method: "POST" }),
getTranscriptTemplate: () =>
request<ApiResponse<Record<string, unknown>>>(
"/api/system/transcript-template",
),
saveTranscriptTemplate: (data: Record<string, unknown>) =>
request<ApiResponse<Record<string, unknown>>>(
"/api/system/transcript-template",
{
method: "PUT",
body: JSON.stringify(data),
},
),
previewTranscriptTemplate: async (
data: Record<string, unknown>,
): Promise<string> => {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const res = await fetch("/api/system/transcript-template/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(data),
signal: controller.signal,
});
if (!res.ok) {
throw new Error(`Preview failed (${res.status})`);
}
return await res.text();
} finally {
window.clearTimeout(timeout);
}
},
getNotifications: () =>
request<ApiResponse<NotificationsPayload>>("/api/notifications"),
markNotificationsRead: (ids: string[] | "all") =>
request<ApiResponse<{ unreadCount: number }>>("/api/notifications/read", {
method: "POST",
body: JSON.stringify({ ids }),
}),
dismissNotification: (id: string) =>
request<ApiResponse<{ unreadCount: number }>>(`/api/notifications/${id}`, {
method: "DELETE",
}),
listDashboardUsers: () =>
request<
ApiResponse<{
users: StoredDashboardUser[];
roles: DashboardRole[];
configFiles: string[];
}>
>("/api/dashboard-users"),
addDashboardUser: (body: {
id: string;
role: DashboardRole;
username?: string;
globalName?: string;
permissions?: DashboardPermissions;
enabled?: boolean;
}) =>
request<ApiResponse<StoredDashboardUser>>("/api/dashboard-users", {
method: "POST",
body: JSON.stringify(body),
}),
updateDashboardUser: (
id: string,
body: {
role?: DashboardRole;
enabled?: boolean;
username?: string;
globalName?: string;
permissions?: DashboardPermissions;
},
) =>
request<ApiResponse<StoredDashboardUser>>(`/api/dashboard-users/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
removeDashboardUser: (id: string) =>
request<ApiResponse<unknown>>(`/api/dashboard-users/${id}`, {
method: "DELETE",
}),
getAddonsCatalog: () =>
request<
ApiResponse<{
repository: string;
addons: CatalogAddon[];
}>
>("/api/addons/catalog"),
getAddonsInstalled: () =>
request<
ApiResponse<{
installed: { id: string; name: string; jsFiles: string[]; configFiles: string[] }[];
configs: string[];
}>
>("/api/addons/installed"),
listAddonConfigs: () =>
request<ApiResponse<string[]>>("/api/addons/configs"),
installAddon: (name: string) =>
request<ApiResponse<unknown>>("/api/addons/install", {
method: "POST",
body: JSON.stringify({ name }),
}),
getAddonConfigJson: (file: string) =>
request<ApiResponse<Record<string, unknown>>>(
`/api/addons/configs/json/${encodeURIComponent(file)}`,
),
saveAddonConfig: (filename: string, data: Record<string, unknown>) =>
request<ApiResponse<unknown>>("/api/addons/configs", {
method: "PUT",
body: JSON.stringify({ filename, data }),
}),
getSetupStatus: () =>
request<ApiResponse<import("@/lib/setup-types").SetupStatus>>("/api/setup/status"),
validateSetup: (body: Record<string, unknown>) =>
request<ApiResponse<import("@/lib/setup-types").SetupValidateResponse>>(
"/api/setup/validate",
{ method: "POST", body: JSON.stringify(body) },
),
completeSetup: (body: Record<string, unknown>) =>
request<
ApiResponse<{ setupComplete: boolean; botRestart?: BotRestartResult }>
>("/api/setup/complete", {
method: "POST",
body: JSON.stringify(body),
}),
};
export function displayDiscordName(
user: Pick<DashboardUser, "username" | "globalName">,
): string {
return user.globalName || user.username;
}
export function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
+26
View File
@@ -0,0 +1,26 @@
import { Outlet } from "react-router-dom";
import { AppSidebar } from "@/components/app-sidebar";
import { DashboardHeader } from "@/components/dashboard-header";
import { DashboardAlerts } from "@/components/dashboard-alerts";
import { BotStatsProvider } from "@/context/BotStatsContext";
import { NotificationsProvider } from "@/context/NotificationsContext";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
export default function Layout() {
return (
<BotStatsProvider>
<NotificationsProvider>
<SidebarProvider>
<AppSidebar />
<SidebarInset className="min-h-svh bg-background">
<DashboardHeader />
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6 lg:p-8">
<DashboardAlerts />
<Outlet />
</div>
</SidebarInset>
</SidebarProvider>
</NotificationsProvider>
</BotStatsProvider>
);
}
@@ -0,0 +1,166 @@
import { useCallback, useEffect, useState } from "react";
import { AlertCircleIcon, FloppyDiskIcon } from "@hugeicons/core-free-icons";
import { api } from "@/api/client";
import { ConfigFieldInput } from "@/components/config/config-field";
import { Icon } from "@/components/icon";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { schemaFromObject } from "@/lib/generic-config-schema";
import { getByPath, setByPath } from "@/lib/config-utils";
import { useAuth } from "@/context/AuthContext";
import { notifyConfigSave } from "@/lib/notify-config-save";
interface AddonConfigEditorProps {
filename: string;
}
export function AddonConfigEditor({ filename }: AddonConfigEditorProps) {
const { canEditConfig } = useAuth();
const readOnly = !canEditConfig("supportbot");
const [data, setData] = useState<Record<string, unknown> | null>(null);
const [original, setOriginal] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const load = useCallback(async () => {
setLoading(true);
setError("");
setMessage("");
try {
const res = await api.getAddonConfigJson(filename);
const raw = (res.data ?? {}) as Record<string, unknown>;
setData(raw);
setOriginal(structuredClone(raw));
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load config");
} finally {
setLoading(false);
}
}, [filename]);
useEffect(() => {
load();
}, [load]);
const schema = data ? schemaFromObject(data) : null;
function patch(path: string, value: unknown) {
setData((prev) => (prev ? setByPath(prev, path, value) : prev));
}
async function handleSave() {
if (readOnly || !data) return;
setSaving(true);
setError("");
setMessage("");
try {
const res = await notifyConfigSave(() =>
api.saveAddonConfig(filename, data),
);
setMessage(res.message || "Saved.");
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
}
if (loading) {
return (
<div className="space-y-4">
<Skeleton className="h-24 w-full rounded-xl" />
<Skeleton className="h-48 w-full rounded-xl" />
</div>
);
}
if (!data || !schema) {
return (
<Alert variant="destructive">
<AlertDescription>{error || "Failed to load"}</AlertDescription>
</Alert>
);
}
const dirty =
original && JSON.stringify(data) !== JSON.stringify(original);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-end gap-2">
{dirty ? (
<span className="text-xs text-amber-500">Unsaved changes</span>
) : null}
<Button
variant="outline"
size="sm"
onClick={load}
disabled={loading || saving}
>
Reload
</Button>
{!readOnly ? (
<Button size="sm" onClick={handleSave} disabled={saving || !dirty}>
<Icon icon={FloppyDiskIcon} size={16} className="mr-1.5" />
{saving ? "Saving…" : "Save"}
</Button>
) : (
<span className="text-xs text-muted-foreground">View only</span>
)}
</div>
{error ? (
<Alert variant="destructive">
<Icon icon={AlertCircleIcon} size={18} />
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{message ? (
<Alert className="border-primary/30 bg-primary/10 text-primary">
<AlertDescription>{message}</AlertDescription>
</Alert>
) : null}
{schema.map((section) => (
<Card key={section.title} className="border-border bg-card">
<CardHeader className="pb-3">
<CardTitle className="text-base">{section.title}</CardTitle>
{section.description ? (
<CardDescription>{section.description}</CardDescription>
) : null}
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
{section.fields.map((field) => (
<div
key={field.path}
className={
field.type === "textarea" || field.type === "stringList"
? "sm:col-span-2"
: ""
}
>
<ConfigFieldInput
field={field}
value={getByPath(data, field.path)}
readOnly={readOnly}
onChange={(v) => patch(field.path, v)}
/>
</div>
))}
</CardContent>
</Card>
))}
</div>
);
}
+268
View File
@@ -0,0 +1,268 @@
import type { IconSvgElement } from "@hugeicons/react";
import {
AiBrain01Icon,
ArrowDown01Icon,
BotIcon,
CommandIcon,
ComputerTerminal01Icon,
DashboardSquare02Icon,
File02Icon,
Message01Icon,
PlusSignIcon,
PackageIcon,
Settings02Icon,
Ticket01Icon,
UserMultiple02Icon,
} from "@hugeicons/core-free-icons";
import { NavLink, useSearchParams } from "react-router-dom";
import { useAuth } from "@/context/AuthContext";
import { useBranding } from "@/context/BrandingContext";
import { useBotStats } from "@/context/BotStatsContext";
import { useAddonConfigs } from "@/hooks/use-addon-configs";
import {
addonConfigFileParam,
addonConfigLabel,
} from "@/lib/addon-config";
import { canViewConfig, hasPermission } from "@/lib/permissions";
import { Icon } from "@/components/icon";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
} from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
const mainNav: {
title: string;
url: string;
icon: IconSvgElement;
end?: boolean;
permission?: string;
}[] = [
{ title: "Dashboard", url: "/", icon: DashboardSquare02Icon, end: true, permission: "overview" },
{ title: "Logs", url: "/logs", icon: ComputerTerminal01Icon, permission: "logs" },
{ title: "Settings", url: "/settings", icon: Settings02Icon, permission: "settings.view" },
{ title: "Addons", url: "/addons", icon: PackageIcon, permission: "settings.view" },
{ title: "Users", url: "/users", icon: UserMultiple02Icon, permission: "users.view" },
];
const sections: {
label: string;
items: { title: string; url: string; icon: IconSvgElement }[];
}[] = [
{
label: "Tickets & support",
items: [
{
title: "Ticket system",
url: "/configs?file=ticket-panel",
icon: Ticket01Icon,
},
{
title: "Bot config",
url: "/configs?file=supportbot",
icon: BotIcon,
},
{ title: "Transcripts", url: "/transcripts", icon: File02Icon },
],
},
{
label: "Community",
items: [
{
title: "Messages",
url: "/configs?file=messages",
icon: Message01Icon,
},
{
title: "AI assistant",
url: "/configs?file=supportbot-ai",
icon: AiBrain01Icon,
},
],
},
{
label: "Server control",
items: [
{ title: "commands.yml", url: "/configs?file=commands", icon: CommandIcon },
{
title: "Staff roles",
url: "/configs?file=supportbot",
icon: UserMultiple02Icon,
},
],
},
];
function configFileFromUrl(url: string) {
return new URL(url, "http://x").searchParams.get("file");
}
export function AppSidebar() {
const { branding } = useBranding();
const { permissions } = useAuth();
const { stats } = useBotStats();
const { files: addonConfigFiles } = useAddonConfigs();
const [searchParams] = useSearchParams();
const activeFile = searchParams.get("file");
const addonSection =
canViewConfig(permissions, "supportbot") && addonConfigFiles.length > 0
? {
label: "Addons",
items: addonConfigFiles.map((filename) => ({
title: addonConfigLabel(filename),
url: `/configs?file=${addonConfigFileParam(filename)}`,
icon: PackageIcon,
})),
}
: null;
const visibleMainNav = mainNav.filter(
(item) => !item.permission || hasPermission(permissions, item.permission),
);
const visibleSections = sections
.map((section) => ({
...section,
items: section.items.filter((item) => {
const file = configFileFromUrl(item.url);
if (file && item.url.includes("/configs")) {
return canViewConfig(permissions, file);
}
if (item.url === "/transcripts") {
return hasPermission(permissions, "transcripts");
}
if (item.url === "/commands") {
return canViewConfig(permissions, "commands");
}
return true;
}),
}))
.filter((section) => section.items.length > 0);
const allSections = addonSection
? [...visibleSections, addonSection]
: visibleSections;
const serverLabel = stats
? `${stats.servers} server${stats.servers === 1 ? "" : "s"}`
: "Your server";
return (
<Sidebar collapsible="icon" className="border-r border-sidebar-border">
<SidebarHeader className="gap-3 p-4">
<div className="flex items-center gap-2 px-1">
{branding.hasFavicon && branding.faviconUrl ? (
<img
src={`${branding.faviconUrl}${branding.updatedAt ? `?v=${branding.updatedAt}` : ""}`}
alt=""
className="size-7 shrink-0 rounded-md object-contain"
/>
) : null}
<span className="truncate text-lg font-bold tracking-tight">
{branding.title}
</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="h-10 w-full justify-between border-border bg-secondary/40 px-3 font-normal hover:bg-secondary/60"
>
<span className="flex items-center gap-2 truncate">
<Icon icon={PlusSignIcon} size={16} className="text-muted-foreground" />
<span className="truncate">{serverLabel}</span>
</span>
<Icon icon={ArrowDown01Icon} size={16} className="text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[--radix-dropdown-menu-trigger-width]">
<DropdownMenuItem disabled>Connected via API</DropdownMenuItem>
{stats ? (
<DropdownMenuItem disabled>
{stats.users.toLocaleString()} members tracked
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</SidebarHeader>
<SidebarContent className="px-2">
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{visibleMainNav.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild tooltip={item.title}>
<NavLink
to={item.url}
end={item.end}
className={({ isActive }) =>
cn(isActive && "bg-sidebar-accent font-medium")
}
>
<Icon icon={item.icon} size={18} />
<span>{item.title}</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{allSections.map((section) => (
<SidebarGroup key={section.label}>
<SidebarGroupLabel className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/80">
{section.label}
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{section.items.map((item) => {
const file = configFileFromUrl(item.url);
const isActive =
Boolean(file) &&
activeFile === file &&
item.url.includes("/configs");
return (
<SidebarMenuItem key={`${section.label}-${item.title}`}>
<SidebarMenuButton asChild tooltip={item.title}>
<NavLink
to={item.url}
className={cn(
isActive && "bg-sidebar-accent font-medium",
)}
>
<Icon icon={item.icon} size={18} />
<span>{item.title}</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
<SidebarRail />
</Sidebar>
);
}
@@ -0,0 +1,106 @@
import type { IconSvgElement } from "@hugeicons/react";
import { CommandIcon } from "@hugeicons/core-free-icons";
import { Icon } from "@/components/icon";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
export interface CommandEntry {
key: string;
Command?: string;
Description?: string;
Enabled?: boolean;
Permission?: string[];
}
interface CommandsEditorProps {
data: Record<string, unknown>;
onChange: (data: Record<string, unknown>) => void;
}
function isCommandBlock(v: unknown): v is CommandEntry {
return (
v !== null &&
typeof v === "object" &&
"Command" in (v as Record<string, unknown>)
);
}
export function getCommandEntries(
data: Record<string, unknown>,
): CommandEntry[] {
return Object.entries(data)
.filter(([, v]) => isCommandBlock(v))
.map(([key, v]) => ({ key, ...(v as CommandEntry) }));
}
export function CommandsEditor({ data, onChange }: CommandsEditorProps) {
const entries = getCommandEntries(data);
function updateEntry(
key: string,
patch: Partial<CommandEntry>,
) {
const current = data[key] as Record<string, unknown>;
onChange({
...data,
[key]: { ...current, ...patch },
});
}
if (entries.length === 0) {
return (
<p className="text-sm text-muted-foreground">
No commands found in this config.
</p>
);
}
return (
<div className="grid gap-3 sm:grid-cols-2">
{entries.map((entry) => (
<Card key={entry.key} className="border-border bg-card">
<CardHeader className="flex flex-row items-center gap-2 space-y-0 pb-2">
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/15 text-primary">
<Icon icon={CommandIcon as IconSvgElement} size={18} />
</div>
<CardTitle className="text-base">{entry.key}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor={`${entry.key}-enabled`}>Enabled</Label>
<Switch
id={`${entry.key}-enabled`}
checked={entry.Enabled !== false}
onCheckedChange={(checked) =>
updateEntry(entry.key, { Enabled: checked })
}
/>
</div>
<div className="space-y-1.5">
<Label>Slash command</Label>
<Input
value={entry.Command ?? ""}
onChange={(e) =>
updateEntry(entry.key, { Command: e.target.value })
}
className="border-border bg-secondary/30 font-mono"
/>
</div>
<div className="space-y-1.5">
<Label>Description</Label>
<Input
value={entry.Description ?? ""}
onChange={(e) =>
updateEntry(entry.key, { Description: e.target.value })
}
className="border-border bg-secondary/30"
/>
</div>
</CardContent>
</Card>
))}
</div>
);
}
@@ -0,0 +1,248 @@
import type { ConfigField } from "@/lib/config-schemas";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ColorPicker } from "@/components/ui/color-picker";
import { normalizeHex } from "@/lib/normalize-hex";
import { StringListEditor } from "./string-list-editor";
import { GoogleFontPicker } from "./google-font-picker";
import {
DiscordResourcePicker,
type DiscordResourceKind,
} from "./discord-resource-picker";
import { EmojiPicker } from "./emoji-picker";
const SECRET_PLACEHOLDER = "BOT_TOKEN";
const AI_KEY_PLACEHOLDER = "MODEL_API_KEY";
interface ConfigFieldInputProps {
field: ConfigField;
value: unknown;
onChange: (value: unknown) => void;
readOnly?: boolean;
}
export function ConfigFieldInput({
field,
value,
onChange,
readOnly,
}: ConfigFieldInputProps) {
const id = field.path.replace(/\./g, "-");
if (field.type === "googleFont") {
return (
<GoogleFontPicker
field={field}
value={value}
onChange={onChange}
readOnly={readOnly}
/>
);
}
if (field.type === "boolean") {
return (
<div className="flex items-center justify-between gap-4 rounded-lg border border-border bg-secondary/20 px-3 py-2.5">
<div>
<Label htmlFor={id} className="font-medium">
{field.label}
</Label>
{field.description ? (
<p className="text-xs text-muted-foreground">{field.description}</p>
) : null}
</div>
<Switch
id={id}
checked={Boolean(value)}
disabled={readOnly}
onCheckedChange={onChange}
/>
</div>
);
}
if (field.type === "stringList") {
return (
<div className="space-y-2">
<Label>{field.label}</Label>
{field.description ? (
<p className="text-xs text-muted-foreground">{field.description}</p>
) : null}
<StringListEditor
value={Array.isArray(value) ? (value as string[]) : []}
onChange={onChange}
/>
</div>
);
}
if (field.type === "select" && field.options) {
return (
<div className="space-y-2">
<Label htmlFor={id}>{field.label}</Label>
<Select
value={String(value ?? "")}
disabled={readOnly}
onValueChange={onChange}
>
<SelectTrigger id={id} className="border-border bg-secondary/30">
<SelectValue placeholder="Select…" />
</SelectTrigger>
<SelectContent>
{field.options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
if (field.type === "color") {
const hex = normalizeHex(String(value ?? "#000000"));
return (
<div className="space-y-2">
<Label htmlFor={id}>{field.label}</Label>
{field.description ? (
<p className="text-xs text-muted-foreground">{field.description}</p>
) : null}
<div className="flex gap-2">
<ColorPicker value={hex} onChange={onChange} />
<Input
id={id}
value={hex}
disabled={readOnly}
onChange={(e) => onChange(e.target.value)}
onBlur={(e) => onChange(normalizeHex(e.target.value, hex))}
className="border-border bg-secondary/30 font-mono uppercase"
placeholder="#000000"
maxLength={7}
/>
</div>
</div>
);
}
if (field.type === "emoji") {
return (
<EmojiPicker
id={id}
label={field.label}
description={field.description}
value={value}
onChange={onChange}
readOnly={readOnly}
/>
);
}
if (
field.type === "discordChannel" ||
field.type === "discordRole" ||
field.type === "discordCategory"
) {
const kind: DiscordResourceKind =
field.type === "discordRole"
? "role"
: field.type === "discordCategory"
? "category"
: "channel";
return (
<DiscordResourcePicker
id={id}
label={field.label}
description={field.description}
kind={kind}
value={value}
onChange={onChange}
readOnly={readOnly}
/>
);
}
if (field.type === "secret") {
const display =
value === SECRET_PLACEHOLDER ||
value === AI_KEY_PLACEHOLDER ||
!value
? ""
: "••••••••••••";
return (
<div className="space-y-2">
<Label htmlFor={id}>{field.label}</Label>
{field.description ? (
<p className="text-xs text-muted-foreground">{field.description}</p>
) : null}
<Input
id={id}
type="password"
value={display}
disabled={readOnly}
placeholder={field.placeholder ?? "Unchanged if empty"}
onChange={(e) => {
const v = e.target.value;
if (v) onChange(v);
else onChange(field.placeholder ?? SECRET_PLACEHOLDER);
}}
className="border-border bg-secondary/30"
/>
</div>
);
}
const sharedClass = "border-border bg-secondary/30";
if (field.type === "textarea") {
return (
<div className="space-y-2">
<Label htmlFor={id}>{field.label}</Label>
<Textarea
id={id}
value={String(value ?? "")}
disabled={readOnly}
onChange={(e) => onChange(e.target.value)}
className={`min-h-[88px] ${sharedClass}`}
/>
</div>
);
}
return (
<div className="space-y-2">
<Label htmlFor={id}>{field.label}</Label>
{field.description ? (
<p className="text-xs text-muted-foreground">{field.description}</p>
) : null}
<Input
id={id}
type={field.type === "number" ? "number" : "text"}
value={value === undefined || value === null ? "" : String(value)}
disabled={readOnly}
onChange={(e) =>
onChange(
field.type === "number"
? Number(e.target.value)
: e.target.value,
)
}
placeholder={
field.placeholder ??
(field.type === "discordId" ? "Discord snowflake ID" : undefined)
}
className={sharedClass}
/>
</div>
);
}
@@ -0,0 +1,346 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { AlertCircleIcon, FloppyDiskIcon } from "@hugeicons/core-free-icons";
import { api, maskConfigSecrets } from "@/api/client";
import { Icon } from "@/components/icon";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { getSchema, type ConfigSchema } from "@/lib/config-schemas";
import { getByPath, setByPath } from "@/lib/config-utils";
import { mergeSchemaWithData } from "@/lib/merge-config-schema";
import { useAuth } from "@/context/AuthContext";
import { notifyConfigSave } from "@/lib/notify-config-save";
import { Textarea } from "@/components/ui/textarea";
import { ConfigFieldInput } from "./config-field";
import { CommandsEditor } from "./commands-editor";
type EditorMode = "visual" | "yaml";
interface ConfigVisualEditorProps {
configFile: string;
}
function maskSecretsInData(
file: string,
data: Record<string, unknown>,
): Record<string, unknown> {
let next = structuredClone(data);
if (file === "supportbot") {
const token = getByPath(next, "General.Token");
if (token && token !== "BOT_TOKEN") {
next = setByPath(next, "General.Token", "BOT_TOKEN");
}
}
if (file === "supportbot-ai") {
const key = getByPath(next, "General.Model_API_Key");
if (key && key !== "MODEL_API_KEY") {
next = setByPath(next, "General.Model_API_Key", "MODEL_API_KEY");
}
}
return next;
}
function buildUpdates(
file: string,
schema: ConfigSchema,
data: Record<string, unknown>,
original: Record<string, unknown>,
): Record<string, unknown> {
const updates: Record<string, unknown> = {};
for (const section of schema) {
for (const field of section.fields) {
const val = getByPath(data, field.path);
const orig = getByPath(original, field.path);
if (field.type === "secret") {
if (
val === "BOT_TOKEN" ||
val === "MODEL_API_KEY" ||
val === "" ||
val === undefined
) {
continue;
}
}
if (JSON.stringify(val) !== JSON.stringify(orig)) {
updates[`${file}:${field.path}`] = val;
}
}
}
return updates;
}
export function ConfigVisualEditor({ configFile }: ConfigVisualEditorProps) {
const { canEditConfig } = useAuth();
const readOnly = !canEditConfig(configFile);
const [data, setData] = useState<Record<string, unknown> | null>(null);
const [original, setOriginal] = useState<Record<string, unknown> | null>(
null,
);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const [mode, setMode] = useState<EditorMode>("visual");
const [rawYaml, setRawYaml] = useState("");
const [rawOriginal, setRawOriginal] = useState("");
const baseSchema = getSchema(configFile);
const effectiveSchema = useMemo(() => {
if (!baseSchema || baseSchema === "commands" || !data) return baseSchema;
return mergeSchemaWithData(baseSchema, data);
}, [baseSchema, data]);
const load = useCallback(async () => {
setLoading(true);
setError("");
setMessage("");
try {
const res = await api.getConfigJson(configFile);
const raw = (res.data ?? {}) as Record<string, unknown>;
const masked = maskSecretsInData(configFile, raw);
setData(masked);
setOriginal(masked);
const rawRes = await api.getConfigRaw(configFile);
const content = rawRes.data ?? "";
const maskedRaw = maskConfigSecrets(content, configFile);
setRawYaml(maskedRaw);
setRawOriginal(maskedRaw);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load config");
} finally {
setLoading(false);
}
}, [configFile]);
useEffect(() => {
load();
}, [load]);
function patch(path: string, value: unknown) {
setData((prev) => (prev ? setByPath(prev, path, value) : prev));
}
async function handleSaveYaml() {
if (readOnly || rawYaml === rawOriginal) return;
setSaving(true);
setError("");
setMessage("");
try {
const res = await notifyConfigSave(() =>
api.saveConfigRaw(configFile, rawYaml),
);
setMessage(res.message || "Config saved.");
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
}
async function handleSave() {
if (readOnly || !data || !original) return;
if (mode === "yaml") {
await handleSaveYaml();
return;
}
setSaving(true);
setError("");
setMessage("");
try {
if (baseSchema === "commands") {
const updates: Record<string, unknown> = {};
for (const key of Object.keys(data)) {
if (JSON.stringify(data[key]) !== JSON.stringify(original[key])) {
updates[`${configFile}:${key}`] = data[key];
}
}
if (Object.keys(updates).length === 0) {
setMessage("No changes to save.");
return;
}
const res = await notifyConfigSave(() =>
api.updateConfigFields(updates),
);
setMessage(res.message || "");
} else if (effectiveSchema && effectiveSchema !== "commands") {
const updates = buildUpdates(
configFile,
effectiveSchema,
data,
original,
);
if (Object.keys(updates).length === 0) {
setMessage("No changes to save.");
return;
}
const res = await notifyConfigSave(() =>
api.updateConfigFields(updates),
);
setMessage(res.message || "");
}
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
}
if (baseSchema === null) {
return (
<Alert>
<AlertDescription>
No visual editor available for this file.
</AlertDescription>
</Alert>
);
}
if (loading) {
return (
<div className="space-y-4">
<Skeleton className="h-24 w-full rounded-xl" />
<Skeleton className="h-48 w-full rounded-xl" />
</div>
);
}
if (!data) {
return (
<Alert variant="destructive">
<AlertDescription>{error || "Failed to load"}</AlertDescription>
</Alert>
);
}
const dirtyVisual =
original && JSON.stringify(data) !== JSON.stringify(original);
const dirtyYaml = rawYaml !== rawOriginal;
const dirty = mode === "yaml" ? dirtyYaml : dirtyVisual;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex rounded-lg border border-border p-0.5">
<Button
type="button"
variant={mode === "visual" ? "secondary" : "ghost"}
size="sm"
className="h-8"
onClick={() => setMode("visual")}
>
Visual
</Button>
<Button
type="button"
variant={mode === "yaml" ? "secondary" : "ghost"}
size="sm"
className="h-8"
onClick={() => setMode("yaml")}
>
YAML
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
{dirty ? (
<span className="text-xs text-amber-500">Unsaved changes</span>
) : null}
<Button
variant="outline"
size="sm"
onClick={load}
disabled={loading || saving}
>
Reload
</Button>
{!readOnly ? (
<Button size="sm" onClick={handleSave} disabled={saving || !dirty}>
<Icon icon={FloppyDiskIcon} size={16} className="mr-1.5" />
{saving ? "Saving…" : "Save"}
</Button>
) : (
<span className="text-xs text-muted-foreground">View only</span>
)}
</div>
</div>
{error ? (
<Alert variant="destructive">
<Icon icon={AlertCircleIcon} size={18} />
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{message ? (
<Alert className="border-primary/30 bg-primary/10 text-primary">
<AlertDescription>{message}</AlertDescription>
</Alert>
) : null}
{mode === "yaml" ? (
<Card className="border-border bg-card">
<CardHeader className="pb-3">
<CardTitle className="text-base">Raw YAML</CardTitle>
<CardDescription>
Edit the full file. Use this for nested lists (e.g. welcome buttons)
or any option not in Visual mode.
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={rawYaml}
disabled={readOnly}
onChange={(e) => setRawYaml(e.target.value)}
className="min-h-[min(520px,60vh)] font-mono text-xs leading-relaxed"
spellCheck={false}
/>
</CardContent>
</Card>
) : baseSchema === "commands" ? (
<CommandsEditor
data={data}
onChange={(next) => setData(next)}
/>
) : effectiveSchema && effectiveSchema !== "commands" ? (
effectiveSchema.map((section) => (
<Card key={section.title} className="border-border bg-card">
<CardHeader className="pb-3">
<CardTitle className="text-base">{section.title}</CardTitle>
{section.description ? (
<CardDescription>{section.description}</CardDescription>
) : null}
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
{section.fields.map((field) => (
<div
key={field.path}
className={
field.type === "textarea" || field.type === "stringList"
? "sm:col-span-2"
: ""
}
>
<ConfigFieldInput
field={field}
value={getByPath(data, field.path)}
readOnly={readOnly}
onChange={(v) => patch(field.path, v)}
/>
</div>
))}
</CardContent>
</Card>
))
) : null}
</div>
);
}
@@ -0,0 +1,136 @@
import { useEffect, useMemo, useState } from "react";
import { api } from "@/api/client";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export type DiscordResourceKind = "channel" | "role" | "category";
interface DiscordResourcePickerProps {
id: string;
label: string;
description?: string;
kind: DiscordResourceKind;
value: unknown;
onChange: (value: unknown) => void;
readOnly?: boolean;
}
import type { GuildResources } from "@/api/client";
export function DiscordResourcePicker({
id,
label,
description,
kind,
value,
onChange,
readOnly,
}: DiscordResourcePickerProps) {
const [resources, setResources] = useState<GuildResources | null>(null);
const [loadError, setLoadError] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
api
.getGuildResources()
.then((res) => {
if (cancelled) return;
if (res.data) {
setResources(res.data);
setLoadError("");
}
})
.catch((err) => {
if (!cancelled) {
setLoadError(
err instanceof Error ? err.message : "Could not load server list",
);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const options = useMemo(() => {
if (!resources) return [];
if (kind === "role") return resources.roles;
if (kind === "category") return resources.categories;
return resources.channels;
}, [resources, kind]);
const current = String(value ?? "");
const matched = options.find((o) => o.id === current);
return (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
{description ? (
<p className="text-xs text-muted-foreground">{description}</p>
) : null}
{resources ? (
<Select
value={matched ? current : ""}
disabled={readOnly || loading}
onValueChange={(next) => onChange(next)}
>
<SelectTrigger id={id} className="border-border bg-secondary/30">
<SelectValue
placeholder={
loading
? "Loading…"
: `Select ${kind} from ${resources.guildName}…`
}
/>
</SelectTrigger>
<SelectContent className="max-h-64">
{options.map((opt) => (
<SelectItem key={opt.id} value={opt.id}>
{opt.name}
<span className="ml-2 font-mono text-xs text-muted-foreground">
{opt.id}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
<Input
value={current}
disabled={readOnly}
onChange={(e) => onChange(e.target.value)}
placeholder={
kind === "role"
? "Role ID (ROLE_ID)"
: kind === "category"
? "Category ID"
: "Channel ID"
}
className="border-border bg-secondary/30 font-mono text-sm"
/>
{loadError ? (
<p className="text-xs text-amber-500">{loadError}</p>
) : null}
{!loadError && !resources && !loading ? (
<p className="text-xs text-muted-foreground">
Set General.GuildId and invite the bot to pick from a list.
</p>
) : null}
</div>
);
}
@@ -0,0 +1,304 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Search01Icon } from "@hugeicons/core-free-icons";
import { api, type GuildResources } from "@/api/client";
import { Icon } from "@/components/icon";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { UnicodeEmojiPanel } from "@/components/config/unicode-emoji-panel";
import {
emojiCdnUrl,
formatCustomEmoji,
parseEmojiValue,
resolveEmojiPreview,
type GuildEmojiOption,
} from "@/lib/emoji-format";
import { cn } from "@/lib/utils";
interface EmojiPickerProps {
id: string;
label: string;
description?: string;
value: unknown;
onChange: (value: unknown) => void;
readOnly?: boolean;
}
function EmojiPreview({
value,
guildEmojis,
size = "md",
}: {
value: unknown;
guildEmojis: GuildEmojiOption[];
size?: "md" | "lg";
}) {
const parsed = resolveEmojiPreview(value, guildEmojis);
const imgSize = size === "lg" ? "size-10" : "size-7";
const textSize = size === "lg" ? "text-3xl" : "text-2xl";
if (parsed.kind === "empty") {
return (
<span className="text-sm text-muted-foreground">No emoji selected</span>
);
}
if (parsed.kind === "custom" && parsed.id) {
return (
<span className="inline-flex items-center gap-3">
<img
src={emojiCdnUrl(parsed.id, parsed.animated)}
alt={parsed.name ?? "custom emoji"}
className={cn(imgSize, "shrink-0 rounded-md")}
loading="lazy"
/>
<span className="flex flex-col items-start gap-0.5">
<span className="text-sm font-medium">:{parsed.name}:</span>
<span className="font-mono text-xs text-muted-foreground">
{parsed.value}
</span>
</span>
</span>
);
}
return (
<span className={cn(textSize, "leading-none")} title={parsed.value}>
{parsed.value}
</span>
);
}
export function EmojiPicker({
id,
label,
description,
value,
onChange,
readOnly,
}: EmojiPickerProps) {
const [open, setOpen] = useState(false);
const [tab, setTab] = useState<"server" | "unicode">("server");
const [resources, setResources] = useState<GuildResources | null>(null);
const [loadError, setLoadError] = useState("");
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState("");
const loadGuildEmojis = useCallback(async (refresh = false) => {
setLoading(true);
setLoadError("");
try {
const res = await api.getGuildResources(refresh);
if (res.data) {
setResources(res.data);
if (!Array.isArray(res.data.emojis)) {
setLoadError(
"Server response is missing emojis. Restart the bot after updating.",
);
}
} else {
setLoadError("No guild data returned.");
}
} catch (err) {
setLoadError(
err instanceof Error ? err.message : "Could not load server emojis",
);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!open) return;
void loadGuildEmojis(true);
}, [open, loadGuildEmojis]);
const guildEmojis = useMemo(
() => (Array.isArray(resources?.emojis) ? resources.emojis : []),
[resources],
);
const filteredGuild = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return guildEmojis;
return guildEmojis.filter((e) => e.name.toLowerCase().includes(q));
}, [guildEmojis, query]);
const current = String(value ?? "");
const parsed = parseEmojiValue(current);
function selectUnicode(emoji: string) {
onChange(emoji);
setOpen(false);
}
function selectGuild(emoji: GuildEmojiOption) {
onChange(formatCustomEmoji(emoji.name, emoji.id, emoji.animated));
setOpen(false);
}
function clearEmoji() {
onChange("");
}
return (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
{description ? (
<p className="text-xs text-muted-foreground">{description}</p>
) : null}
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<EmojiPreview value={value} guildEmojis={guildEmojis} size="lg" />
</div>
<div className="flex shrink-0 gap-2">
<Button
type="button"
variant="secondary"
size="sm"
disabled={readOnly}
onClick={() => setOpen(true)}
>
Choose emoji
</Button>
{current ? (
<Button
type="button"
variant="ghost"
size="sm"
disabled={readOnly}
onClick={clearEmoji}
>
Clear
</Button>
) : null}
</div>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[min(90vh,720px)] max-w-lg gap-0 overflow-hidden p-0 sm:max-w-xl">
<DialogHeader className="space-y-1 border-b border-border px-5 py-4">
<DialogTitle>Choose emoji</DialogTitle>
<DialogDescription>
Server emojis save as{" "}
<code className="rounded bg-muted px-1 text-xs">&lt;:name:id&gt;</code>
. Standard emojis save as Unicode characters.
</DialogDescription>
</DialogHeader>
<Tabs
value={tab}
onValueChange={(v) => setTab(v as "server" | "unicode")}
className="flex min-h-0 flex-1 flex-col"
>
<TabsList className="mx-5 mt-3 grid h-9 w-auto grid-cols-2">
<TabsTrigger value="server">Server</TabsTrigger>
<TabsTrigger value="unicode">All emojis</TabsTrigger>
</TabsList>
<TabsContent value="server" className="mt-0 flex-1 px-5 pb-4 pt-3">
<div className="relative mb-3">
<Icon
icon={Search01Icon}
size={16}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder="Search by name…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-9 border-border bg-secondary/30 pl-9"
/>
</div>
{resources ? (
<p className="mb-2 text-xs text-muted-foreground">
{resources.guildName} · {guildEmojis.length} custom emoji
{guildEmojis.length === 1 ? "" : "s"}
</p>
) : null}
<ScrollArea className="h-[min(50vh,320px)] rounded-lg border border-border/50 bg-secondary/20">
{loading ? (
<div className="grid grid-cols-6 gap-2 p-3 sm:grid-cols-8">
{Array.from({ length: 24 }).map((_, i) => (
<Skeleton key={i} className="aspect-square rounded-lg" />
))}
</div>
) : loadError ? (
<div className="space-y-3 p-4 text-center">
<p className="text-sm text-destructive">{loadError}</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void loadGuildEmojis(true)}
>
Retry
</Button>
</div>
) : filteredGuild.length === 0 ? (
<p className="p-6 text-center text-sm text-muted-foreground">
{guildEmojis.length === 0
? "No custom emojis in this server, or the bot needs the Guild Emojis intent (restart after update)."
: "No emojis match your search."}
</p>
) : (
<div className="grid grid-cols-6 gap-1.5 p-2 sm:grid-cols-8">
{filteredGuild.map((emoji) => {
const selected =
parsed.kind === "custom" && parsed.id === emoji.id;
return (
<button
key={emoji.id}
type="button"
title={`:${emoji.name}:`}
className={cn(
"flex aspect-square items-center justify-center rounded-lg border border-transparent bg-background/50 transition-colors hover:border-primary/30 hover:bg-accent",
selected &&
"border-primary/50 bg-primary/15 ring-2 ring-primary/30",
)}
onClick={() => selectGuild(emoji)}
>
<img
src={emoji.url ?? emojiCdnUrl(emoji.id, emoji.animated)}
alt={emoji.name}
className="size-8 object-contain"
loading="lazy"
/>
</button>
);
})}
</div>
)}
</ScrollArea>
</TabsContent>
<TabsContent value="unicode" className="mt-0 px-5 pb-4 pt-3">
<UnicodeEmojiPanel onPick={selectUnicode} />
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
<Input
value={current}
disabled={readOnly}
onChange={(e) => onChange(e.target.value)}
placeholder="Unicode or <:name:id>"
className="border-border bg-secondary/30 font-mono text-xs"
/>
</div>
);
}
@@ -0,0 +1,74 @@
import { useEffect } from "react";
import type { ConfigField } from "@/lib/config-schemas";
import {
GOOGLE_FONTS,
SYSTEM_FONT_ID,
fontFamilyCss,
injectGoogleFontsStylesheet,
normalizeGoogleFontValue,
} from "@/lib/google-fonts";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface GoogleFontPickerProps {
field: ConfigField;
value: unknown;
onChange: (value: unknown) => void;
readOnly?: boolean;
}
export function GoogleFontPicker({
field,
value,
onChange,
readOnly,
}: GoogleFontPickerProps) {
const id = field.path.replace(/\./g, "-");
const selected = normalizeGoogleFontValue(value);
useEffect(() => {
injectGoogleFontsStylesheet();
}, []);
return (
<div className="space-y-2">
<Label htmlFor={id}>{field.label}</Label>
{field.description ? (
<p className="text-xs text-muted-foreground">{field.description}</p>
) : null}
<Select
value={selected}
disabled={readOnly}
onValueChange={onChange}
>
<SelectTrigger
id={id}
className="border-border bg-secondary/30"
style={{ fontFamily: fontFamilyCss(selected) }}
>
<SelectValue placeholder="Choose a font…" />
</SelectTrigger>
<SelectContent className="max-h-[min(320px,50vh)]">
<SelectItem value={SYSTEM_FONT_ID} style={{ fontFamily: fontFamilyCss(SYSTEM_FONT_ID) }}>
System default
</SelectItem>
{GOOGLE_FONTS.map((font) => (
<SelectItem
key={font.id}
value={font.id}
style={{ fontFamily: fontFamilyCss(font.id) }}
>
{font.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
@@ -0,0 +1,56 @@
import { Delete02Icon, PlusSignIcon } from "@hugeicons/core-free-icons";
import { Icon } from "@/components/icon";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface StringListEditorProps {
value: string[];
onChange: (value: string[]) => void;
placeholder?: string;
}
export function StringListEditor({
value,
onChange,
placeholder = "Add item…",
}: StringListEditorProps) {
const items = Array.isArray(value) ? value : [];
return (
<div className="space-y-2">
{items.map((item, index) => (
<div key={index} className="flex gap-2">
<Input
value={item}
onChange={(e) => {
const next = [...items];
next[index] = e.target.value;
onChange(next);
}}
placeholder={placeholder}
className="border-border bg-secondary/30"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0 text-destructive hover:bg-destructive/10"
onClick={() => onChange(items.filter((_, i) => i !== index))}
>
<Icon icon={Delete02Icon} size={16} />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full border-dashed border-border"
onClick={() => onChange([...items, ""])}
>
<Icon icon={PlusSignIcon} size={16} className="mr-2" />
Add line
</Button>
</div>
);
}
@@ -0,0 +1,41 @@
import { useEffect, useRef } from "react";
import "emoji-picker-element";
interface UnicodeEmojiPanelProps {
onPick: (unicode: string) => void;
}
export function UnicodeEmojiPanel({ onPick }: UnicodeEmojiPanelProps) {
const hostRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const picker = document.createElement("emoji-picker");
picker.className = "unicode-emoji-picker-el";
picker.setAttribute("data-theme", "dark");
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ unicode?: string; emoji?: { unicode?: string } }>)
.detail;
const unicode = detail?.unicode ?? detail?.emoji?.unicode;
if (unicode) onPick(unicode);
};
picker.addEventListener("emoji-click", handler);
host.appendChild(picker);
return () => {
picker.removeEventListener("emoji-click", handler);
picker.remove();
};
}, [onPick]);
return (
<div
ref={hostRef}
className="unicode-emoji-panel-host overflow-hidden rounded-lg border border-border/50"
/>
);
}
@@ -0,0 +1,112 @@
import { Link } from "react-router-dom";
import {
AlertCircleIcon,
InformationCircleIcon,
Logout03Icon,
} from "@hugeicons/core-free-icons";
import { Icon } from "@/components/icon";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
SEVERITY_STYLES,
type DashboardAlertPayload,
type DashboardAlertRenderContext,
type DashboardAlertSeverity,
} from "@/lib/dashboard-alerts";
function severityIcon(severity: DashboardAlertSeverity) {
if (severity === "info") return InformationCircleIcon;
return AlertCircleIcon;
}
interface DashboardAlertCardProps {
alert: DashboardAlertPayload;
context: DashboardAlertRenderContext;
}
export function DashboardAlertCard({ alert, context }: DashboardAlertCardProps) {
const styles = SEVERITY_STYLES[alert.severity];
const extraGuilds = alert.meta?.extraGuilds ?? [];
const guildStatus = alert.meta?.status;
const moduleProgress = alert.meta?.progress;
return (
<Alert
variant="destructive"
className={`${styles.border} ${styles.bg} text-foreground`}
>
<Icon icon={severityIcon(alert.severity)} size={18} className={styles.icon} />
<AlertTitle className={styles.title}>{alert.title}</AlertTitle>
<AlertDescription className="space-y-3 text-sm text-muted-foreground">
<p>{alert.message}</p>
{alert.category === "guild" && guildStatus === "missing_config" ? (
<p>
Open{" "}
<Link
to="/configs?file=supportbot"
className="font-medium text-primary underline-offset-4 hover:underline"
>
Bot config
</Link>{" "}
and set <strong className="text-foreground">Server (guild) ID</strong>{" "}
(enable Developer Mode in Discord, then right-click your server → Copy
Server ID).
</p>
) : null}
{alert.category === "modules" && moduleProgress ? (
<p className="text-xs">
Progress: <span className="text-foreground">{moduleProgress}</span>
</p>
) : null}
{extraGuilds.length > 0 ? (
<ul className="space-y-2">
{extraGuilds.map((g) => (
<li
key={g.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border bg-background/40 px-3 py-2"
>
<span>
<span className="font-medium text-foreground">{g.name}</span>
<span className="ml-2 font-mono text-xs">{g.id}</span>
</span>
{context.canLeaveGuild ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={context.leavingGuildId === g.id}
onClick={() => void context.onLeaveGuild(g.id)}
>
<Icon icon={Logout03Icon} size={14} className="mr-1.5" />
{context.leavingGuildId === g.id ? "Leaving…" : "Leave server"}
</Button>
) : null}
</li>
))}
</ul>
) : null}
{context.actionError ? (
<p className="text-destructive">{context.actionError}</p>
) : null}
<div className="flex flex-wrap items-center gap-2 pt-1">
{alert.action ? (
<Button asChild size="sm" variant="secondary">
<Link to={alert.action.href}>{alert.action.label}</Link>
</Button>
) : null}
{alert.category === "guild" ? (
<p className="text-xs">
The dashboard stays online while you fix this. Some features may be
limited until setup is complete.
</p>
) : null}
</div>
</AlertDescription>
</Alert>
);
}
@@ -0,0 +1,65 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "@/api/client";
import { useAuth } from "@/context/AuthContext";
import {
dashboardAlertBuilder,
type DashboardAlertPayload,
} from "@/lib/dashboard-alerts";
import { DashboardAlertCard } from "./dashboard-alert-card";
const REFRESH_MS = 15_000;
export function DashboardAlerts() {
const { hasPermission } = useAuth();
const canLeave = hasPermission("settings.update");
const [alerts, setAlerts] = useState<DashboardAlertPayload[]>([]);
const [leavingGuildId, setLeavingGuildId] = useState<string | null>(null);
const [actionError, setActionError] = useState("");
const refresh = useCallback(async () => {
try {
const next = await dashboardAlertBuilder.loadAll();
setAlerts(next);
setActionError("");
} catch {
/* ignore — banners are best-effort */
}
}, []);
useEffect(() => {
void refresh();
const id = window.setInterval(() => void refresh(), REFRESH_MS);
return () => window.clearInterval(id);
}, [refresh]);
async function leaveGuild(guildId: string) {
if (!canLeave) return;
setLeavingGuildId(guildId);
setActionError("");
try {
await api.leaveGuild(guildId);
await refresh();
} catch (err) {
setActionError(err instanceof Error ? err.message : "Could not leave server");
} finally {
setLeavingGuildId(null);
}
}
if (alerts.length === 0) return null;
const renderContext = {
canLeaveGuild: canLeave,
onLeaveGuild: leaveGuild,
leavingGuildId,
actionError,
};
return (
<div className="space-y-3">
{alerts.map((alert) => (
<DashboardAlertCard key={alert.id} alert={alert} context={renderContext} />
))}
</div>
);
}
@@ -0,0 +1,2 @@
export { DashboardAlerts } from "./dashboard-alerts";
export { DashboardAlertCard } from "./dashboard-alert-card";
@@ -0,0 +1,117 @@
import {
ArrowDown01Icon,
PlusSignIcon,
} from "@hugeicons/core-free-icons";
import { DashboardSearch } from "@/components/dashboard-search";
import { useBotStats } from "@/context/BotStatsContext";
import { Icon } from "@/components/icon";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { NotificationBell } from "@/components/notification-bell";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { displayDiscordName, useAuth } from "@/context/AuthContext";
export function DashboardHeader() {
const { stats } = useBotStats();
const { user, logout } = useAuth();
const botName = stats?.bot.username ?? "SupportBot";
const accountName = user ? displayDiscordName(user) : botName;
const accountAvatar = user?.avatar ?? stats?.bot.avatar;
const inviteUrl = stats?.bot.inviteUrl;
const dateLabel = new Intl.DateTimeFormat("en-US", {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date());
return (
<header className="relative z-40 flex h-14 shrink-0 items-center gap-3 border-b border-border bg-card px-4 md:px-6">
<SidebarTrigger className="-ml-1 text-muted-foreground hover:text-foreground md:hidden" />
<p className="hidden shrink-0 text-sm font-medium text-muted-foreground lg:block">
{dateLabel}
</p>
<DashboardSearch className="min-w-0 flex-1" />
<div className="ml-auto flex items-center gap-2">
{inviteUrl ? (
<Button
variant="ghost"
size="icon"
className="hidden size-9 text-muted-foreground sm:inline-flex"
asChild
>
<a
href={inviteUrl}
target="_blank"
rel="noopener noreferrer"
aria-label="Invite bot to a server"
title="Invite bot to a server"
>
<Icon icon={PlusSignIcon} size={18} />
</a>
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="hidden size-9 text-muted-foreground sm:inline-flex"
disabled
aria-label="Invite bot to a server"
title="Invite link available when the bot is online"
>
<Icon icon={PlusSignIcon} size={18} />
</Button>
)}
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-9 gap-2 rounded-full px-2 hover:bg-secondary"
>
<Avatar className="size-7">
<AvatarImage src={accountAvatar} alt={accountName} />
<AvatarFallback className="bg-primary/20 text-xs text-primary">
{accountName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="hidden max-w-[100px] truncate text-sm font-medium sm:inline">
{accountName}
</span>
<Icon icon={ArrowDown01Icon} size={16} className="text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>Account</DropdownMenuLabel>
<DropdownMenuSeparator />
{user ? (
<DropdownMenuItem disabled className="text-xs text-muted-foreground">
{user.username}
{user.id ? ` · ${user.id}` : ""}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
onClick={() => {
void logout();
}}
>
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
}
@@ -0,0 +1,174 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useNavigate } from "react-router-dom";
import { Search01Icon } from "@hugeicons/core-free-icons";
import { Icon } from "@/components/icon";
import { Input } from "@/components/ui/input";
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { cn } from "@/lib/utils";
import {
groupSearchResults,
searchDashboard,
type SearchItem,
} from "@/lib/search-index";
export function DashboardSearch({ className }: { className?: string }) {
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [panelRect, setPanelRect] = useState<{
top: number;
left: number;
width: number;
} | null>(null);
const results = useMemo(() => searchDashboard(query, 14), [query]);
const groups = useMemo(() => groupSearchResults(results), [results]);
const showPanel = open && query.trim().length > 0;
const updatePanelRect = () => {
const el = inputRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setPanelRect({
top: rect.bottom + 6,
left: rect.left,
width: rect.width,
});
};
useLayoutEffect(() => {
if (!showPanel) {
setPanelRect(null);
return;
}
updatePanelRect();
window.addEventListener("resize", updatePanelRect);
window.addEventListener("scroll", updatePanelRect, true);
return () => {
window.removeEventListener("resize", updatePanelRect);
window.removeEventListener("scroll", updatePanelRect, true);
};
}, [showPanel, query]);
useEffect(() => {
function onPointerDown(e: MouseEvent) {
const target = e.target as Node;
if (
containerRef.current?.contains(target) ||
panelRef.current?.contains(target)
) {
return;
}
setOpen(false);
}
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, []);
function selectItem(item: SearchItem) {
navigate(item.href);
setQuery("");
setOpen(false);
inputRef.current?.blur();
}
return (
<div ref={containerRef} className={cn("relative w-full max-w-sm", className)}>
<Icon
icon={Search01Icon}
size={16}
className="pointer-events-none absolute left-3 top-1/2 z-10 -translate-y-1/2 text-muted-foreground"
/>
<Input
ref={inputRef}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false);
setQuery("");
inputRef.current?.blur();
}
}}
placeholder="Search modules, configs…"
className="h-9 border-border bg-secondary/50 pl-9"
role="combobox"
aria-expanded={showPanel}
aria-controls="dashboard-search-results"
aria-autocomplete="list"
/>
{showPanel && panelRect
? createPortal(
<div
ref={panelRef}
id="dashboard-search-results"
className="overflow-hidden rounded-xl border border-border bg-popover shadow-lg shadow-black/20"
style={{
position: "fixed",
top: panelRect.top,
left: panelRect.left,
width: panelRect.width,
zIndex: 9999,
}}
role="listbox"
>
<Command shouldFilter={false} className="bg-transparent">
<CommandList className="max-h-[min(360px,50vh)]">
{results.length === 0 ? (
<CommandEmpty className="py-8 text-muted-foreground">
No results for &ldquo;{query.trim()}&rdquo;
</CommandEmpty>
) : (
groups.map((group) => (
<CommandGroup key={group.category} heading={group.label}>
{group.items.map((item) => (
<CommandItem
key={item.id}
value={item.id}
onSelect={() => selectItem(item)}
className="cursor-pointer gap-3 rounded-lg py-2.5 aria-selected:bg-accent"
>
{item.icon ? (
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/15 text-primary">
<Icon icon={item.icon} size={16} />
</span>
) : null}
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{item.title}
</span>
{item.subtitle ? (
<span className="block truncate text-xs text-muted-foreground">
{item.subtitle}
</span>
) : null}
</span>
</CommandItem>
))}
</CommandGroup>
))
)}
</CommandList>
</Command>
</div>,
document.body,
)
: null}
</div>
);
}
@@ -0,0 +1,14 @@
import { Navigate } from "react-router-dom";
import { useAuth } from "@/context/AuthContext";
import { firstAllowedPath, hasPermission } from "@/lib/permissions";
import Overview from "@/pages/Overview";
export function HomeRedirect() {
const { permissions } = useAuth();
if (hasPermission(permissions, "overview")) {
return <Overview />;
}
return <Navigate to={firstAllowedPath(permissions)} replace />;
}
+26
View File
@@ -0,0 +1,26 @@
import { HugeiconsIcon } from "@hugeicons/react";
import type { IconSvgElement } from "@hugeicons/react";
import { cn } from "@/lib/utils";
interface IconProps {
icon: IconSvgElement;
className?: string;
size?: number;
strokeWidth?: number;
}
export function Icon({
icon,
className,
size = 18,
strokeWidth = 1.75,
}: IconProps) {
return (
<HugeiconsIcon
icon={icon}
size={size}
className={cn("shrink-0", className)}
strokeWidth={strokeWidth}
/>
);
}
@@ -0,0 +1,168 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import {
Cancel01Icon,
Notification01Icon,
} from "@hugeicons/core-free-icons";
import { useNotifications } from "@/context/NotificationsContext";
import { Icon } from "@/components/icon";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { DashboardNotification } from "@/api/client";
import { cn } from "@/lib/utils";
function formatRelativeTime(ts: number) {
const sec = Math.floor((Date.now() - ts) / 1000);
if (sec < 60) return "Just now";
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
return `${day}d ago`;
}
function typeDotClass(type: DashboardNotification["type"]) {
switch (type) {
case "error":
return "bg-destructive";
case "update":
return "bg-amber-500";
case "bot":
return "bg-emerald-500";
default:
return "bg-primary";
}
}
export function NotificationBell() {
const navigate = useNavigate();
const { items, unreadCount, loading, markAllRead, dismiss, refresh } =
useNotifications();
const [open, setOpen] = useState(false);
const onOpenChange = (next: boolean) => {
setOpen(next);
if (next) {
void refresh();
if (unreadCount > 0) void markAllRead();
}
};
const onSelect = (n: DashboardNotification) => {
setOpen(false);
if (n.href) navigate(n.href);
};
return (
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative size-9 text-muted-foreground"
aria-label="Notifications"
>
<Icon icon={Notification01Icon} size={18} />
{unreadCount > 0 && (
<Badge className="absolute -right-0.5 -top-0.5 flex size-4 min-w-4 items-center justify-center rounded-full bg-destructive p-0 text-[10px] text-destructive-foreground">
{unreadCount > 9 ? "9+" : unreadCount}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between px-3 py-2">
<DropdownMenuLabel className="p-0 text-sm font-semibold">
Notifications
</DropdownMenuLabel>
{items.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground"
onClick={() => void markAllRead()}
>
Mark all read
</Button>
)}
</div>
<DropdownMenuSeparator className="m-0" />
<ScrollArea className="max-h-[min(360px,50vh)]">
{loading && items.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
Loading…
</p>
) : items.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
No notifications yet. Bot restarts and update checks will appear
here.
</p>
) : (
<ul className="py-1">
{items.map((n) => (
<li
key={n.id}
className={cn(
"group flex gap-2 border-b border-border/40 px-3 py-2.5 last:border-0",
!n.read && "bg-primary/5",
)}
>
<span
className={cn(
"mt-1.5 size-2 shrink-0 rounded-full",
typeDotClass(n.type),
)}
/>
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => onSelect(n)}
>
<p className="text-sm font-medium leading-tight">
{n.title}
</p>
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">
{n.message}
</p>
<p className="mt-1 text-[10px] text-muted-foreground/80">
{formatRelativeTime(n.createdAt)}
</p>
</button>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0 opacity-0 group-hover:opacity-100"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
void dismiss(n.id);
}}
>
<Icon icon={Cancel01Icon} size={14} />
</Button>
</li>
))}
</ul>
)}
</ScrollArea>
<DropdownMenuSeparator className="m-0" />
<div className="p-2">
<Button variant="ghost" size="sm" className="w-full text-xs" asChild>
<Link to="/settings" onClick={() => setOpen(false)}>
Open settings (updates)
</Link>
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,52 @@
import { Link } from "react-router-dom";
import type { IconSvgElement } from "@hugeicons/react";
import { Icon } from "@/components/icon";
import { cn } from "@/lib/utils";
import type { ModuleStatus } from "@/components/overview/module-strip-card";
const BADGE_CLASS: Record<ModuleStatus, string> = {
todo: "bg-rose-500/15 text-rose-400",
progress: "bg-amber-500/15 text-amber-400",
done: "bg-emerald-500/15 text-emerald-400",
};
interface ActiveModuleCardProps {
title: string;
icon: IconSvgElement;
href: string;
badge?: string;
status?: ModuleStatus;
}
export function ActiveModuleCard({
title,
icon,
href,
badge = "Configured",
status = "done",
}: ActiveModuleCardProps) {
return (
<Link
to={href}
className={cn(
"flex items-center gap-3 rounded-2xl border border-border bg-card p-4 transition-colors",
"hover:border-primary hover:bg-secondary",
)}
>
<div className="flex size-11 shrink-0 items-center justify-center rounded-full bg-secondary text-primary">
<Icon icon={icon} size={20} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-semibold">{title}</p>
<span
className={cn(
"mt-1 inline-flex rounded-md px-2 py-0.5 text-[10px] font-semibold",
BADGE_CLASS[status],
)}
>
{badge}
</span>
</div>
</Link>
);
}
@@ -0,0 +1,62 @@
import { cn } from "@/lib/utils";
interface DonutChartProps {
percent: number;
size?: number;
className?: string;
trackClassName?: string;
strokeClassName?: string;
}
export function DonutChart({
percent,
size = 128,
className,
trackClassName = "stroke-white/20",
strokeClassName = "stroke-white",
}: DonutChartProps) {
const r = 42;
const c = 2 * Math.PI * r;
const clamped = Math.min(100, Math.max(0, percent));
const offset = c - (clamped / 100) * c;
return (
<div className={cn("relative inline-flex", className)}>
<svg
width={size}
height={size}
viewBox="0 0 100 100"
className="-rotate-90"
aria-hidden
>
<circle
cx="50"
cy="50"
r={r}
fill="none"
strokeWidth="9"
className={trackClassName}
/>
<circle
cx="50"
cy="50"
r={r}
fill="none"
strokeWidth="9"
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={offset}
className={strokeClassName}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">
<span className="text-2xl font-bold tabular-nums leading-none">
{Math.round(clamped)}%
</span>
<span className="mt-0.5 text-[10px] font-medium uppercase tracking-wide opacity-80">
Complete
</span>
</div>
</div>
);
}
@@ -0,0 +1,102 @@
import { Link } from "react-router-dom";
import {
ArrowRight01Icon,
Link01Icon,
Settings02Icon,
} from "@hugeicons/core-free-icons";
import type { IconSvgElement } from "@hugeicons/react";
import { Icon } from "@/components/icon";
import { cn } from "@/lib/utils";
export type ModuleStatus = "todo" | "progress" | "done";
const statusStyles: Record<
ModuleStatus,
{ dot: string; badge: string; label: string }
> = {
todo: {
dot: "bg-rose-500",
badge: "bg-rose-500/15 text-rose-400",
label: "Needs setup",
},
progress: {
dot: "bg-amber-400",
badge: "bg-amber-500/15 text-amber-400",
label: "In progress",
},
done: {
dot: "bg-sky-400",
badge: "bg-sky-500/15 text-sky-400",
label: "Active",
},
};
interface ModuleStripCardProps {
title: string;
subtitle: string;
icon: IconSvgElement;
href: string;
status: ModuleStatus;
meta?: string;
}
export function ModuleStripCard({
title,
subtitle,
icon,
href,
status,
meta = "Open in dashboard",
}: ModuleStripCardProps) {
const s = statusStyles[status];
return (
<Link
to={href}
className={cn(
"group flex min-w-[280px] flex-1 flex-col gap-4 rounded-2xl border border-border bg-card p-4 transition-colors",
"hover:border-primary hover:bg-secondary",
"sm:min-w-[300px] lg:min-w-0",
)}
>
<div className="flex items-start justify-between gap-2">
<span className={cn("mt-1.5 size-2 shrink-0 rounded-full", s.dot)} />
<span className="text-xs text-muted-foreground">{subtitle}</span>
</div>
<div>
<h3 className="font-semibold leading-snug tracking-tight">{title}</h3>
<span
className={cn(
"mt-2 inline-flex rounded-md px-2 py-0.5 text-[11px] font-semibold",
s.badge,
)}
>
{s.label}
</span>
</div>
<div className="mt-auto flex items-center justify-between border-t border-border pt-3">
<div className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-lg bg-secondary text-primary">
<Icon icon={icon} size={16} />
</div>
<span className="text-xs text-muted-foreground">{meta}</span>
</div>
<div className="flex items-center gap-3 text-muted-foreground">
<Icon icon={Link01Icon} size={14} className="opacity-60" />
<Icon
icon={Settings02Icon}
size={14}
className="opacity-60 group-hover:text-primary"
/>
<Icon
icon={ArrowRight01Icon}
size={14}
className="text-primary opacity-0 transition-opacity group-hover:opacity-100"
/>
</div>
</div>
</Link>
);
}
@@ -0,0 +1,163 @@
import { Link } from "react-router-dom";
import {
ArrowRight01Icon,
Download04Icon,
Rocket01Icon,
Settings02Icon,
} from "@hugeicons/core-free-icons";
import type { BotStats } from "@/api/client";
import { formatUptime } from "@/api/client";
import { Icon } from "@/components/icon";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface OverviewAsideProps {
stats: BotStats;
}
export function OverviewAside({ stats }: OverviewAsideProps) {
const activity = [
{
id: "ping",
label: "Gateway latency",
detail: `${stats.bot.ping}ms`,
badge: stats.bot.ping < 200 ? "Good" : "High",
badgeClass:
stats.bot.ping < 200
? "bg-emerald-500/15 text-emerald-400"
: "bg-amber-500/15 text-amber-400",
},
{
id: "uptime",
label: "Bot uptime",
detail: formatUptime(stats.bot.uptime),
badge: "Live",
badgeClass: "bg-primary/20 text-primary",
},
{
id: "ram",
label: "Memory usage",
detail: `${stats.hosting.ram_used} / ${stats.hosting.ram_total}`,
badge: `${stats.hosting.ram_percent}%`,
badgeClass: "bg-sky-500/15 text-sky-400",
},
];
return (
<aside className="hidden w-[min(100%,320px)] shrink-0 space-y-4 xl:block">
<Card className="border-border bg-card">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-semibold">
Connected servers ({stats.servers})
</CardTitle>
<Button variant="ghost" size="icon" className="size-7 text-muted-foreground">
<span className="text-lg leading-none">+</span>
</Button>
</CardHeader>
<CardContent className="pt-0">
<div className="flex -space-x-2">
{Array.from({ length: Math.min(6, stats.servers) }).map((_, i) => (
<Avatar
key={i}
className="size-9 border-2 border-card ring-0"
>
<AvatarImage src={stats.bot.avatar} alt="" />
<AvatarFallback className="bg-primary/25 text-[10px] text-primary">
{String(i + 1)}
</AvatarFallback>
</Avatar>
))}
{stats.servers > 6 ? (
<div className="flex size-9 items-center justify-center rounded-full border-2 border-card bg-secondary text-xs font-medium text-muted-foreground">
+{stats.servers - 6}
</div>
) : null}
</div>
<p className="mt-3 text-xs text-muted-foreground">
{stats.users.toLocaleString()} members across your guilds
</p>
</CardContent>
</Card>
<Card className="overflow-hidden border-border bg-secondary">
<CardContent className="p-5">
<div className="space-y-3">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary text-primary-foreground">
<Icon icon={Rocket01Icon} size={22} />
</div>
<div>
<p className="font-semibold">Keep SupportBot up to date</p>
<p className="mt-1 text-xs text-muted-foreground">
Check for releases and install updates from settings.
</p>
</div>
<Button asChild size="sm" className="w-full">
<Link to="/settings">
<Icon icon={Download04Icon} size={16} className="mr-2" />
Open settings
</Link>
</Button>
</div>
</CardContent>
</Card>
<Card className="border-border bg-card">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-semibold">System activity</CardTitle>
<Button variant="ghost" size="icon" className="size-7 text-muted-foreground">
<span className="text-base leading-none">⋯</span>
</Button>
</CardHeader>
<CardContent className="space-y-1 p-0 pb-2">
{activity.map((item) => (
<div
key={item.id}
className="flex items-center gap-3 px-4 py-2.5 transition-colors hover:bg-secondary/40"
>
<Avatar className="size-9">
<AvatarImage src={stats.bot.avatar} alt={stats.bot.username} />
<AvatarFallback className="bg-secondary text-xs">
SB
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{item.label}</p>
<p className="truncate text-xs text-muted-foreground">
{item.detail}
</p>
</div>
<span
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold ${item.badgeClass}`}
>
{item.badge}
</span>
</div>
))}
<Button
asChild
variant="ghost"
className="mx-2 mt-1 w-[calc(100%-1rem)] justify-between text-xs text-muted-foreground"
>
<Link to="/system">
All system details
<Icon icon={ArrowRight01Icon} size={14} />
</Link>
</Button>
</CardContent>
</Card>
<Card className="border-border bg-card">
<CardContent className="flex items-center gap-3 p-4">
<div className="flex size-9 items-center justify-center rounded-lg bg-secondary text-primary">
<Icon icon={Settings02Icon} size={18} />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">v{stats.bot.version}</p>
<p className="text-xs text-muted-foreground">CPU {stats.hosting.cpu_load}</p>
</div>
</CardContent>
</Card>
</aside>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { Link } from "react-router-dom";
import type { IconSvgElement } from "@hugeicons/react";
import { Icon } from "@/components/icon";
import { cn } from "@/lib/utils";
interface PluginCardProps {
title: string;
description: string;
icon: IconSvgElement;
href: string;
}
export function PluginCard({
title,
description,
icon,
href,
}: PluginCardProps) {
return (
<Link
to={href}
className={cn(
"group flex flex-col rounded-2xl border border-border bg-card p-5 transition-colors",
"hover:border-primary hover:bg-secondary",
)}
>
<div className="mb-4 flex size-10 items-center justify-center rounded-xl bg-secondary text-primary">
<Icon icon={icon} size={20} />
</div>
<h3 className="mb-2 font-semibold tracking-tight">{title}</h3>
<p className="line-clamp-3 text-sm leading-relaxed text-muted-foreground">
{description}
</p>
</Link>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { Link } from "react-router-dom";
import { SparklesIcon } from "@hugeicons/core-free-icons";
import { Icon } from "@/components/icon";
import { Button } from "@/components/ui/button";
import { useBotStats } from "@/context/BotStatsContext";
export function PromoBanner() {
const { stats } = useBotStats();
const version = stats?.bot.version ?? "26.1.1";
return (
<div className="rounded-2xl border border-border bg-card p-6 md:p-8">
<div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
<div className="max-w-lg space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Icon icon={SparklesIcon} size={16} />
<span>SupportBot {version}</span>
</div>
<h2 className="text-2xl font-bold tracking-tight md:text-3xl">
Manage tickets, configs & transcripts
</h2>
<p className="text-sm text-muted-foreground md:text-base">
Configure your bot from one place. Edit settings visually, review
ticket transcripts, and monitor live stats.
</p>
<Button asChild variant="secondary" className="mt-2">
<Link to="/configs">Open settings</Link>
</Button>
</div>
<div className="hidden shrink-0 md:block">
<div className="flex size-36 items-center justify-center rounded-2xl border border-border bg-secondary text-6xl">
🤖
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,28 @@
import { Navigate } from "react-router-dom";
import { useAuth } from "@/context/AuthContext";
import { firstAllowedPath, hasPermission } from "@/lib/permissions";
export function RequirePermission({
permission,
children,
}: {
permission: string;
children: React.ReactNode;
}) {
const { loading, isAuthenticated, permissions } = useAuth();
if (loading) {
return (
<div className="flex flex-1 items-center justify-center py-16 text-sm text-muted-foreground">
Loading…
</div>
);
}
if (!isAuthenticated) return <Navigate to="/login" replace />;
if (!hasPermission(permissions, permission)) {
return <Navigate to={firstAllowedPath(permissions)} replace />;
}
return <>{children}</>;
}
@@ -0,0 +1,230 @@
import { useEffect, useRef, useState } from "react";
import { ImageUpload01Icon } from "@hugeicons/core-free-icons";
import { api } from "@/api/client";
import { Icon } from "@/components/icon";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useBranding } from "@/context/BrandingContext";
function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error("Could not read file"));
reader.readAsDataURL(file);
});
}
interface DashboardBrandingSettingsProps {
canEdit: boolean;
}
export function DashboardBrandingSettings({ canEdit }: DashboardBrandingSettingsProps) {
const { branding, setBrandingLocal } = useBranding();
const fileRef = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState(branding.title);
const [pageTitle, setPageTitle] = useState(branding.pageTitle);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [message, setMessage] = useState("");
useEffect(() => {
setTitle(branding.title);
setPageTitle(branding.pageTitle);
}, [branding.title, branding.pageTitle]);
const faviconPreview =
branding.hasFavicon && branding.faviconUrl
? `${branding.faviconUrl}${branding.updatedAt ? `?v=${branding.updatedAt}` : ""}`
: null;
async function saveText() {
if (!canEdit) return;
setSaving(true);
setMessage("");
try {
const res = await api.updateBranding({ title, pageTitle });
if (res.data) setBrandingLocal(res.data);
setMessage(res.message || "Branding saved.");
} catch (err) {
setMessage(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
}
async function onFaviconSelected(file: File | null) {
if (!canEdit || !file) return;
setUploading(true);
setMessage("");
try {
const dataUrl = await readFileAsDataUrl(file);
const res = await api.uploadBrandingFavicon(dataUrl);
if (res.data) setBrandingLocal(res.data);
setMessage(res.message || "Favicon uploaded.");
} catch (err) {
setMessage(err instanceof Error ? err.message : "Upload failed");
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
}
async function removeFavicon() {
if (!canEdit) return;
setUploading(true);
setMessage("");
try {
const res = await api.removeBrandingFavicon();
if (res.data) setBrandingLocal(res.data);
setMessage(res.message || "Favicon removed.");
} catch (err) {
setMessage(err instanceof Error ? err.message : "Remove failed");
} finally {
setUploading(false);
}
}
async function resetAll() {
if (!canEdit) return;
setSaving(true);
setMessage("");
try {
const res = await api.resetBranding();
if (res.data) setBrandingLocal(res.data);
setMessage(res.message || "Reset to defaults.");
} catch (err) {
setMessage(err instanceof Error ? err.message : "Reset failed");
} finally {
setSaving(false);
}
}
const textDirty =
title.trim() !== branding.title || pageTitle.trim() !== branding.pageTitle;
return (
<div className="space-y-4 border-t border-border pt-4">
<div>
<p className="text-sm font-medium">Branding</p>
<p className="text-xs text-muted-foreground">
Sidebar title, browser tab title, and favicon — saved on the server for
everyone using this dashboard.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="brand-title">Sidebar title</Label>
<Input
id="brand-title"
value={title}
disabled={!canEdit}
maxLength={64}
onChange={(e) => setTitle(e.target.value)}
className="border-border bg-secondary/30"
placeholder="SupportBot"
/>
</div>
<div className="space-y-2">
<Label htmlFor="brand-page-title">Browser tab title</Label>
<Input
id="brand-page-title"
value={pageTitle}
disabled={!canEdit}
maxLength={80}
onChange={(e) => setPageTitle(e.target.value)}
className="border-border bg-secondary/30"
placeholder="SupportBot Dashboard"
/>
</div>
</div>
<div className="space-y-2">
<Label>Favicon</Label>
<p className="text-xs text-muted-foreground">
PNG, ICO, SVG, JPEG, or WebP — max 512 KB. Shown in the browser tab and
beside the sidebar title.
</p>
<div className="flex flex-wrap items-center gap-3">
<div className="flex size-14 items-center justify-center overflow-hidden rounded-lg border border-border bg-secondary/30">
{faviconPreview ? (
<img
src={faviconPreview}
alt="Favicon preview"
className="size-full object-contain p-1"
/>
) : (
<Icon
icon={ImageUpload01Icon}
size={22}
className="text-muted-foreground"
/>
)}
</div>
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/x-icon,image/vnd.microsoft.icon,image/svg+xml,image/webp,.ico"
className="hidden"
disabled={!canEdit || uploading}
onChange={(e) => void onFaviconSelected(e.target.files?.[0] ?? null)}
/>
{canEdit ? (
<>
<Button
type="button"
variant="outline"
size="sm"
disabled={uploading}
onClick={() => fileRef.current?.click()}
>
{uploading ? "Uploading…" : "Upload favicon"}
</Button>
{branding.hasFavicon ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={uploading}
onClick={() => void removeFavicon()}
>
Remove
</Button>
) : null}
</>
) : (
<span className="text-xs text-muted-foreground">View only</span>
)}
</div>
</div>
{canEdit ? (
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
disabled={saving || !textDirty}
onClick={() => void saveText()}
>
{saving ? "Saving…" : "Save titles"}
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={saving || uploading}
onClick={() => void resetAll()}
>
Reset branding
</Button>
</div>
) : null}
{message ? (
<p className="text-xs text-muted-foreground">{message}</p>
) : null}
</div>
);
}
@@ -0,0 +1,638 @@
import type { IconSvgElement } from "@hugeicons/react";
import {
ArrowLeft01Icon,
ArrowRight01Icon,
BotIcon,
CheckmarkCircle02Icon,
Copy01Icon,
Key01Icon,
Link01Icon,
SecurityLockIcon,
UserMultiple02Icon,
} from "@hugeicons/core-free-icons";
import type { ReactNode } from "react";
import { Icon } from "@/components/icon";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { FieldValidationResult, SetupFormValues } from "@/lib/setup-types";
import { cn } from "@/lib/utils";
export const SETUP_STEP_IDS = [
"botToken",
"secretKey",
"oauth",
"redirectUri",
"owners",
] as const;
export type SetupStepId = (typeof SETUP_STEP_IDS)[number];
export const SETUP_STEPS: {
id: SetupStepId;
title: string;
subtitle: string;
icon: IconSvgElement;
}[] = [
{
id: "botToken",
title: "Bot token",
subtitle: "Connect your Discord bot",
icon: BotIcon,
},
{
id: "secretKey",
title: "Session secret",
subtitle: "Secure dashboard logins",
icon: SecurityLockIcon,
},
{
id: "oauth",
title: "Discord OAuth",
subtitle: "Sign in with Discord",
icon: Key01Icon,
},
{
id: "redirectUri",
title: "Redirect URL",
subtitle: "OAuth callback address",
icon: Link01Icon,
},
{
id: "owners",
title: "Dashboard owners",
subtitle: "Who can access this panel",
icon: UserMultiple02Icon,
},
];
function validationKeyForStep(id: SetupStepId): string {
if (id === "oauth") return "oauth";
if (id === "owners") return "owners";
return id;
}
export function SetupWizardShell({
brandingTitle,
faviconUrl,
faviconVersion,
hasFavicon,
children,
}: {
brandingTitle: string;
faviconUrl?: string | null;
faviconVersion?: number;
hasFavicon: boolean;
children: ReactNode;
}) {
return (
<div className="min-h-svh bg-background">
<div className="mx-auto flex min-h-svh max-w-6xl flex-col px-4 py-6 md:px-8 md:py-10">
<header className="mb-8 flex items-center gap-4">
{hasFavicon && faviconUrl ? (
<img
src={`${faviconUrl}${faviconVersion ? `?v=${faviconVersion}` : ""}`}
alt=""
className="size-12 rounded-2xl border border-border object-contain"
/>
) : (
<div className="flex size-12 items-center justify-center rounded-2xl border border-border bg-primary text-primary-foreground">
<Icon icon={BotIcon} size={24} />
</div>
)}
<div>
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
First-time setup
</p>
<h1 className="text-2xl font-semibold tracking-tight md:text-3xl">
{brandingTitle}
</h1>
</div>
</header>
{children}
</div>
</div>
);
}
export function SetupProgress({
currentIndex,
validation,
}: {
currentIndex: number;
validation: Record<string, FieldValidationResult>;
}) {
const progress = ((currentIndex + 1) / SETUP_STEPS.length) * 100;
return (
<div className="space-y-6">
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
Step {currentIndex + 1} of {SETUP_STEPS.length}
</span>
<span>{Math.round(progress)}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary transition-all duration-300 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<nav className="hidden space-y-1 md:block" aria-label="Setup steps">
{SETUP_STEPS.map((step, index) => {
const key = validationKeyForStep(step.id);
const verified = validation[key]?.ok;
const isCurrent = index === currentIndex;
const isPast = index < currentIndex;
return (
<div
key={step.id}
className={cn(
"flex items-start gap-3 rounded-xl border px-3 py-3 transition-colors",
isCurrent && "border-primary bg-secondary",
!isCurrent && "border-transparent",
isPast && !isCurrent && "opacity-80",
)}
>
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg",
verified
? "bg-emerald-500/15 text-emerald-500"
: isCurrent
? "bg-primary text-primary-foreground"
: "bg-secondary text-muted-foreground",
)}
>
{verified ? (
<Icon icon={CheckmarkCircle02Icon} size={18} />
) : (
<Icon icon={step.icon} size={18} />
)}
</div>
<div className="min-w-0 pt-0.5">
<p
className={cn(
"text-sm font-medium leading-none",
isCurrent && "text-foreground",
!isCurrent && "text-muted-foreground",
)}
>
{step.title}
</p>
<p className="mt-1 text-xs text-muted-foreground">{step.subtitle}</p>
</div>
</div>
);
})}
</nav>
<p className="hidden text-xs leading-relaxed text-muted-foreground md:block">
Each step can be tested with Discord before you continue. You can move back
anytime to change a value.
</p>
</div>
);
}
export function SetupStepCard({
step,
children,
footer,
}: {
step: (typeof SETUP_STEPS)[number];
children: ReactNode;
footer?: ReactNode;
}) {
return (
<div className="flex min-h-[min(520px,70vh)] flex-col rounded-2xl border border-border bg-card">
<div className="border-b border-border px-6 py-5 md:px-8">
<div className="flex items-center gap-3 md:hidden">
<div className="flex size-10 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Icon icon={step.icon} size={20} />
</div>
<div>
<h2 className="text-lg font-semibold">{step.title}</h2>
<p className="text-sm text-muted-foreground">{step.subtitle}</p>
</div>
</div>
<div className="hidden md:block">
<h2 className="text-xl font-semibold tracking-tight">{step.title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{step.subtitle}</p>
</div>
</div>
<div className="flex flex-1 flex-col px-6 py-6 md:px-8">{children}</div>
{footer ? (
<div className="border-t border-border px-6 py-4 md:px-8">{footer}</div>
) : null}
</div>
);
}
export function ValidationBanner({
result,
}: {
result?: FieldValidationResult;
}) {
if (!result) return null;
if (result.ok) {
return (
<Alert className="border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">
<AlertDescription className="space-y-2">
{result.bot ? (
<p>
Connected as <strong>@{result.bot.username}</strong>
{result.applicationId ? (
<span className="text-emerald-600/80 dark:text-emerald-400/80">
{" "}
· App ID {result.applicationId}
</span>
) : null}
</p>
) : null}
{result.users?.length ? (
<ul className="space-y-1">
{result.users.map((u) => (
<li key={u.id} className="flex items-center gap-2">
<Icon icon={CheckmarkCircle02Icon} size={14} />
{u.globalName || u.username}
<span className="font-mono text-xs opacity-80">{u.id}</span>
</li>
))}
</ul>
) : null}
{!result.bot && !result.users?.length ? (
<p>{result.warning || "Verified successfully."}</p>
) : null}
</AlertDescription>
</Alert>
);
}
return (
<Alert variant="destructive">
<AlertDescription>{result.error}</AlertDescription>
</Alert>
);
}
export function BotTokenStep({
values,
setValues,
validation,
validating,
onValidate,
}: {
values: SetupFormValues;
setValues: (v: SetupFormValues) => void;
validation: Record<string, FieldValidationResult>;
validating: boolean;
onValidate: () => void;
}) {
return (
<div className="flex flex-1 flex-col gap-5">
<p className="text-sm leading-relaxed text-muted-foreground">
Open the{" "}
<a
href="https://discord.com/developers/applications"
target="_blank"
rel="noreferrer"
className="font-medium text-primary hover:underline"
>
Discord Developer Portal
</a>
, select your application, go to <strong>Bot</strong>, and reset or copy
the token.
</p>
<div className="space-y-2">
<Label htmlFor="botToken">Bot token</Label>
<Input
id="botToken"
type="password"
autoComplete="off"
value={values.botToken}
onChange={(e) => setValues({ ...values, botToken: e.target.value })}
placeholder="MTI…"
className="h-11 font-mono text-sm"
/>
</div>
<ValidationBanner result={validation.botToken} />
<div className="mt-auto">
<Button
type="button"
variant="secondary"
disabled={validating || !values.botToken}
onClick={onValidate}
>
{validating ? "Checking with Discord…" : "Verify bot token"}
</Button>
</div>
</div>
);
}
export function SecretKeyStep({
values,
setValues,
validation,
validating,
onValidate,
onGenerate,
}: {
values: SetupFormValues;
setValues: (v: SetupFormValues) => void;
validation: Record<string, FieldValidationResult>;
validating: boolean;
onValidate: () => void;
onGenerate: () => void;
}) {
return (
<div className="flex flex-1 flex-col gap-5">
<p className="text-sm leading-relaxed text-muted-foreground">
This random string signs your login cookies. Store it like a password — do
not commit it to public repos.
</p>
<div className="space-y-2">
<Label htmlFor="secretKey">Session secret</Label>
<Input
id="secretKey"
type="password"
autoComplete="off"
value={values.secretKey}
onChange={(e) => setValues({ ...values, secretKey: e.target.value })}
className="h-11 font-mono text-sm"
/>
</div>
<ValidationBanner result={validation.secretKey} />
<div className="mt-auto flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={onGenerate}>
Generate secure secret
</Button>
<Button
type="button"
variant="secondary"
disabled={validating || !values.secretKey}
onClick={onValidate}
>
{validating ? "Checking…" : "Validate"}
</Button>
</div>
</div>
);
}
export function OAuthStep({
values,
setValues,
validation,
validating,
onValidate,
}: {
values: SetupFormValues;
setValues: (v: SetupFormValues) => void;
validation: Record<string, FieldValidationResult>;
validating: boolean;
onValidate: () => void;
}) {
return (
<div className="flex flex-1 flex-col gap-5">
<p className="text-sm leading-relaxed text-muted-foreground">
Use the <strong>same application</strong> as your bot. Under OAuth2, copy
the Client ID and Client Secret.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="clientId">Client ID</Label>
<Input
id="clientId"
value={values.clientId}
onChange={(e) => setValues({ ...values, clientId: e.target.value })}
className="h-11 font-mono text-sm"
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="clientSecret">Client Secret</Label>
<Input
id="clientSecret"
type="password"
autoComplete="off"
value={values.clientSecret}
onChange={(e) =>
setValues({ ...values, clientSecret: e.target.value })
}
className="h-11 font-mono text-sm"
/>
</div>
</div>
<ValidationBanner result={validation.oauth} />
<div className="mt-auto">
<Button
type="button"
variant="secondary"
disabled={validating || !values.clientId || !values.clientSecret}
onClick={onValidate}
>
{validating ? "Checking with Discord…" : "Verify OAuth credentials"}
</Button>
</div>
</div>
);
}
export function RedirectStep({
values,
setValues,
validation,
}: {
values: SetupFormValues;
setValues: (v: SetupFormValues) => void;
validation: Record<string, FieldValidationResult>;
}) {
async function copyUri() {
if (!values.redirectUri) return;
try {
await navigator.clipboard.writeText(values.redirectUri);
} catch {
/* ignore */
}
}
return (
<div className="flex flex-1 flex-col gap-5">
<p className="text-sm leading-relaxed text-muted-foreground">
Add this exact URL under <strong>OAuth2 → Redirects</strong> in the
Developer Portal. It must match character-for-character.
</p>
<div className="space-y-2">
<Label htmlFor="redirectUri">Redirect URI</Label>
<div className="flex gap-2">
<Input
id="redirectUri"
value={values.redirectUri}
onChange={(e) =>
setValues({ ...values, redirectUri: e.target.value })
}
className="h-11 flex-1 font-mono text-xs"
/>
<Button
type="button"
variant="outline"
size="icon"
className="size-11 shrink-0"
onClick={() => void copyUri()}
title="Copy URL"
>
<Icon icon={Copy01Icon} size={18} />
</Button>
</div>
</div>
{validation.redirectUri && !validation.redirectUri.ok ? (
<ValidationBanner result={validation.redirectUri} />
) : (
<Alert className="border-border bg-secondary">
<AlertDescription className="text-sm text-muted-foreground">
We pre-filled this from your current dashboard URL. If you access the
panel from a different domain later, update this and add the new URL in
Discord too.
</AlertDescription>
</Alert>
)}
</div>
);
}
export function OwnersStep({
values,
setValues,
validation,
validating,
onValidate,
}: {
values: SetupFormValues;
setValues: (v: SetupFormValues) => void;
validation: Record<string, FieldValidationResult>;
validating: boolean;
onValidate: () => void;
}) {
return (
<div className="flex flex-1 flex-col gap-5">
<p className="text-sm leading-relaxed text-muted-foreground">
Enable <strong>Developer Mode</strong> in Discord (Settings → Advanced),
then right-click your profile and <strong>Copy User ID</strong>. Add one ID
per line.
</p>
<div className="space-y-2">
<Label htmlFor="owners">Owner user IDs</Label>
<Textarea
id="owners"
value={values.ownerUserIds}
onChange={(e) => setValues({ ...values, ownerUserIds: e.target.value })}
placeholder={"829112572816130058\n987654321098765432"}
className="min-h-[120px] font-mono text-sm"
/>
</div>
<ValidationBanner result={validation.owners} />
<div className="mt-auto">
<Button
type="button"
variant="secondary"
disabled={validating || !values.ownerUserIds.trim()}
onClick={onValidate}
>
{validating ? "Checking with Discord…" : "Verify owner IDs"}
</Button>
</div>
</div>
);
}
export function SetupCompleteScreen({ onContinue }: { onContinue: () => void }) {
return (
<div className="mx-auto flex w-full max-w-lg flex-1 flex-col justify-center py-8">
<div className="rounded-2xl border border-border bg-card px-6 py-10 text-center md:px-10">
<div className="mx-auto mb-6 flex size-16 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Icon icon={CheckmarkCircle02Icon} size={40} />
</div>
<h2 className="text-2xl font-semibold tracking-tight">Setup complete</h2>
<p className="mx-auto mt-3 max-w-sm text-sm leading-relaxed text-muted-foreground">
Your configuration has been saved. Restart the server for changes to take
effect — stop the running process, then start it again (for example{" "}
<code className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs">
npm start
</code>
).
</p>
<Alert className="mt-6 text-left">
<AlertDescription>
After restarting, sign in with Discord to open the dashboard.
</AlertDescription>
</Alert>
<Button type="button" size="lg" className="mt-8 min-w-[220px]" onClick={onContinue}>
Continue to sign in
</Button>
</div>
</div>
);
}
export function SetupNav({
currentIndex,
validating,
saving,
onBack,
onNext,
onFinish,
}: {
currentIndex: number;
validating: boolean;
saving: boolean;
onBack: () => void;
onNext: () => void;
onFinish: () => void;
}) {
const isLast = currentIndex === SETUP_STEPS.length - 1;
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<Button
type="button"
variant="ghost"
disabled={currentIndex === 0 || saving}
onClick={onBack}
>
<Icon icon={ArrowLeft01Icon} size={18} className="mr-1" />
Back
</Button>
<div className="flex flex-1 justify-end gap-2">
{isLast ? (
<Button
type="button"
size="lg"
disabled={saving || validating}
onClick={onFinish}
className="min-w-[200px]"
>
{saving ? "Saving…" : "Finish setup"}
</Button>
) : (
<Button
type="button"
size="lg"
disabled={validating || saving}
onClick={onNext}
>
Continue
<Icon icon={ArrowRight01Icon} size={18} className="ml-1" />
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,240 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { FloppyDiskIcon } from "@hugeicons/core-free-icons";
import { api } from "@/api/client";
import { ConfigFieldInput } from "@/components/config/config-field";
import { Icon } from "@/components/icon";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { getByPath, setByPath } from "@/lib/config-utils";
import { transcriptTemplateSchema } from "@/lib/transcript-template-schema";
import { normalizeGoogleFontValue } from "@/lib/google-fonts";
import type { TranscriptTemplate } from "@/lib/transcript-template-types";
import { useAuth } from "@/context/AuthContext";
import { cn } from "@/lib/utils";
const PREVIEW_DEBOUNCE_MS = 450;
function canEditTemplate(role: string | undefined, hasTranscripts: boolean) {
return hasTranscripts && role !== "viewer";
}
export function TranscriptAppearanceEditor() {
const { user, hasPermission } = useAuth();
const readOnly = !canEditTemplate(user?.role, hasPermission("transcripts"));
const [data, setData] = useState<TranscriptTemplate | null>(null);
const [original, setOriginal] = useState<TranscriptTemplate | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const [previewHtml, setPreviewHtml] = useState("");
const [previewLoading, setPreviewLoading] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError("");
setMessage("");
try {
const res = await api.getTranscriptTemplate();
const tpl = (res.data ?? null) as TranscriptTemplate | null;
if (tpl) {
const normalized = {
...tpl,
Typography: {
...tpl.Typography,
FontFamily: normalizeGoogleFontValue(tpl.Typography?.FontFamily),
},
};
setData(normalized);
setOriginal(structuredClone(normalized));
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load template");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const refreshPreview = useCallback(async (tpl: TranscriptTemplate) => {
setPreviewLoading(true);
try {
const html = await api.previewTranscriptTemplate(tpl);
setPreviewHtml(html);
} catch {
setPreviewHtml(
"<!DOCTYPE html><body style='padding:2rem;font-family:sans-serif'><p>Preview failed to load.</p></body>",
);
} finally {
setPreviewLoading(false);
}
}, []);
useEffect(() => {
if (!data) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
void refreshPreview(data);
}, PREVIEW_DEBOUNCE_MS);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [data, refreshPreview]);
function patch(path: string, value: unknown) {
setData((prev) =>
prev ? (setByPath(prev as Record<string, unknown>, path, value) as TranscriptTemplate) : prev,
);
}
async function handleSave() {
if (readOnly || !data) return;
setSaving(true);
setError("");
setMessage("");
try {
const res = await api.saveTranscriptTemplate(data);
setMessage(res.message || "Saved.");
setOriginal(structuredClone(data));
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
}
const dirty =
data && original && JSON.stringify(data) !== JSON.stringify(original);
if (loading) {
return (
<div className="grid gap-4 lg:grid-cols-2">
<Skeleton className="h-96 rounded-xl" />
<Skeleton className="h-96 rounded-xl" />
</div>
);
}
if (!data) {
return (
<Alert variant="destructive">
<AlertDescription>{error || "Failed to load template"}</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-muted-foreground">
Customize how new ticket transcripts look. Existing saved transcripts are not
changed.
</p>
<div className="flex items-center gap-2">
{dirty ? (
<span className="text-xs text-amber-500">Unsaved changes</span>
) : null}
{!readOnly ? (
<Button size="sm" onClick={handleSave} disabled={saving || !dirty}>
<Icon icon={FloppyDiskIcon} size={16} className="mr-1.5" />
{saving ? "Saving…" : "Save appearance"}
</Button>
) : (
<span className="text-xs text-muted-foreground">View only</span>
)}
</div>
</div>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{message ? (
<Alert className="border-primary/30 bg-primary/10 text-primary">
<AlertDescription>{message}</AlertDescription>
</Alert>
) : null}
<div className="grid min-h-0 gap-4 xl:grid-cols-[minmax(320px,420px)_1fr]">
<ScrollableEditor data={data} readOnly={readOnly} onPatch={patch} />
<Card className="flex min-h-[520px] flex-col overflow-hidden border-border bg-card">
<CardHeader className="pb-2">
<CardTitle className="text-base">Live preview</CardTitle>
<CardDescription>
Sample ticket with your current settings
{previewLoading ? " · updating…" : ""}
</CardDescription>
</CardHeader>
<CardContent className="min-h-0 flex-1 p-4 pt-0">
<iframe
title="Transcript template preview"
srcDoc={previewHtml}
className={cn(
"h-[min(640px,calc(100svh-14rem))] w-full rounded-lg border border-border bg-background",
previewLoading && "opacity-60",
)}
sandbox="allow-same-origin"
/>
</CardContent>
</Card>
</div>
</div>
);
}
function ScrollableEditor({
data,
readOnly,
onPatch,
}: {
data: TranscriptTemplate;
readOnly: boolean;
onPatch: (path: string, value: unknown) => void;
}) {
return (
<div className="max-h-[min(720px,calc(100svh-12rem))] space-y-4 overflow-y-auto pr-1">
{transcriptTemplateSchema.map((section) => (
<Card key={section.title} className="border-border bg-card">
<CardHeader className="pb-3">
<CardTitle className="text-base">{section.title}</CardTitle>
{section.description ? (
<CardDescription>{section.description}</CardDescription>
) : null}
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
{section.fields.map((field) => (
<div
key={field.path}
className={
field.type === "textarea" ? "sm:col-span-2" : ""
}
>
<ConfigFieldInput
field={field}
value={getByPath(data as Record<string, unknown>, field.path)}
readOnly={readOnly}
onChange={(v) => onPatch(field.path, v)}
/>
</div>
))}
</CardContent>
</Card>
))}
</div>
);
}
@@ -0,0 +1,278 @@
import { useCallback, useEffect, useState } from "react";
import { FloppyDiskIcon, RefreshIcon } from "@hugeicons/core-free-icons";
import { api } from "@/api/client";
import { Icon } from "@/components/icon";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/context/AuthContext";
import { cn } from "@/lib/utils";
interface TranscriptSettingsState {
autoDelete: { enabled: boolean; afterDays: number };
publicAccess: { enabled: boolean };
}
function canManageTranscripts(role: string | undefined, hasTranscripts: boolean) {
return hasTranscripts && role !== "viewer";
}
export function TranscriptSettingsPanel() {
const { user, hasPermission } = useAuth();
const readOnly = !canManageTranscripts(user?.role, hasPermission("transcripts"));
const [settings, setSettings] = useState<TranscriptSettingsState | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [purging, setPurging] = useState(false);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const load = useCallback(async () => {
setLoading(true);
setError("");
setMessage("");
try {
const res = await api.getTranscriptSettings();
const data = res.data;
if (data) {
setSettings({
autoDelete: {
enabled: Boolean(data.autoDelete?.enabled),
afterDays: data.autoDelete?.afterDays ?? 30,
},
publicAccess: {
enabled: Boolean(data.publicAccess?.enabled),
},
});
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load settings");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
async function handleSave() {
if (!settings || readOnly) return;
setSaving(true);
setError("");
setMessage("");
try {
const res = await api.updateTranscriptSettings(settings);
if (res.data) {
setSettings({
autoDelete: {
enabled: res.data.autoDelete.enabled,
afterDays: res.data.autoDelete.afterDays,
},
publicAccess: {
enabled: res.data.publicAccess.enabled,
},
});
}
setMessage(res.message ?? "Settings saved.");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save settings");
} finally {
setSaving(false);
}
}
async function handlePurge() {
if (readOnly) return;
setPurging(true);
setError("");
setMessage("");
try {
const res = await api.purgeTranscripts();
setMessage(
res.message ??
(res.data?.skipped
? "Auto-delete is disabled."
: `Removed ${res.data?.deleted ?? 0} transcript(s).`),
);
} catch (err) {
setError(err instanceof Error ? err.message : "Purge failed");
} finally {
setPurging(false);
}
}
if (loading) {
return (
<div className="space-y-4">
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
</div>
);
}
if (!settings) {
return (
<Alert variant="destructive">
<AlertDescription>Could not load transcript settings.</AlertDescription>
</Alert>
);
}
return (
<div className="mx-auto max-w-2xl space-y-4">
{readOnly ? (
<Alert>
<AlertDescription>
You have view-only access. An admin or editor can change these settings.
</AlertDescription>
</Alert>
) : null}
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{message ? (
<Alert>
<AlertDescription>{message}</AlertDescription>
</Alert>
) : null}
<Card className="border-border bg-card">
<CardHeader>
<CardTitle className="text-base">Auto-delete</CardTitle>
<CardDescription>
Remove transcript files automatically after they reach a certain age. The
server checks hourly and whenever you open the library.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<label className="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
className="mt-1 size-4 rounded border-border accent-primary"
checked={settings.autoDelete.enabled}
disabled={readOnly}
onChange={(e) =>
setSettings((s) =>
s
? {
...s,
autoDelete: { ...s.autoDelete, enabled: e.target.checked },
}
: s,
)
}
/>
<span className="text-sm">
<span className="font-medium">Enable auto-delete</span>
<span className="mt-0.5 block text-muted-foreground">
Deletes HTML files in Data/Transcripts based on last modified time.
</span>
</span>
</label>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="transcript-after-days">
Delete after (days)
</label>
<Input
id="transcript-after-days"
type="number"
min={1}
max={3650}
disabled={readOnly || !settings.autoDelete.enabled}
value={settings.autoDelete.afterDays}
onChange={(e) =>
setSettings((s) =>
s
? {
...s,
autoDelete: {
...s.autoDelete,
afterDays: Math.min(
3650,
Math.max(1, parseInt(e.target.value, 10) || 1),
),
},
}
: s,
)
}
className="max-w-[8rem]"
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={readOnly || purging}
onClick={() => void handlePurge()}
>
<Icon icon={RefreshIcon} size={16} className={cn(purging && "animate-spin")} />
Run cleanup now
</Button>
</CardContent>
</Card>
<Card className="border-border bg-card">
<CardHeader>
<CardTitle className="text-base">Public transcripts</CardTitle>
<CardDescription>
When enabled, anyone with the link can view a transcript at{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
{typeof window !== "undefined"
? `${window.location.origin}/transcripts/:id`
: "/transcripts/:id"}
</code>{" "}
without signing in. Channel IDs are numeric but not secret—only enable if
you are comfortable sharing links.
</CardDescription>
</CardHeader>
<CardContent>
<label className="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
className="mt-1 size-4 rounded border-border accent-primary"
checked={settings.publicAccess.enabled}
disabled={readOnly}
onChange={(e) =>
setSettings((s) =>
s
? {
...s,
publicAccess: { enabled: e.target.checked },
}
: s,
)
}
/>
<span className="text-sm">
<span className="font-medium">Allow public transcript URLs</span>
<span className="mt-0.5 block text-muted-foreground">
Dashboard preview and download still require login.
</span>
</span>
</label>
</CardContent>
</Card>
{!readOnly ? (
<Button type="button" disabled={saving} onClick={() => void handleSave()}>
<Icon icon={FloppyDiskIcon} size={16} />
{saving ? "Saving…" : "Save settings"}
</Button>
) : null}
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
+57
View File
@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+76
View File
@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border border-border bg-card text-card-foreground shadow-none",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+28
View File
@@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("grid place-content-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
@@ -0,0 +1,113 @@
import { forwardRef, useMemo, useState } from "react";
import { HexColorPicker } from "react-colorful";
import { cn } from "@/lib/utils";
import { isValidHex, normalizeHex } from "@/lib/normalize-hex";
import { useForwardedRef } from "@/hooks/use-forwarded-ref";
import type { ButtonProps } from "@/components/ui/button";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
interface ColorPickerProps {
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
}
const ColorPicker = forwardRef<
HTMLInputElement,
Omit<ButtonProps, "value" | "onChange" | "onBlur"> & ColorPickerProps
>(
(
{ disabled, value, onChange, onBlur, name, className, size, ...props },
forwardedRef,
) => {
const ref = useForwardedRef(forwardedRef);
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState("");
const parsedValue = useMemo(
() => normalizeHex(value, "#ffffff"),
[value],
);
const displayHex = open && draft ? draft : parsedValue;
function commitHex(raw: string) {
if (isValidHex(raw)) {
onChange(normalizeHex(raw));
}
setDraft("");
}
return (
<Popover
open={open}
onOpenChange={(next) => {
if (!next) commitHex(draft || parsedValue);
setOpen(next);
}}
>
<PopoverTrigger asChild disabled={disabled} onBlur={onBlur}>
<Button
{...props}
type="button"
className={cn(
"size-10 shrink-0 overflow-hidden rounded-md border border-border p-0 shadow-sm",
className,
)}
name={name}
onClick={() => setOpen(true)}
size={size ?? "icon"}
style={{ backgroundColor: parsedValue }}
variant="outline"
aria-label="Pick colour"
>
<span className="sr-only">{parsedValue}</span>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto border-border bg-card p-3"
align="start"
>
<HexColorPicker
className="color-picker-swatch"
color={parsedValue}
onChange={(hex) => {
setDraft("");
onChange(normalizeHex(hex));
}}
/>
<Input
ref={ref}
maxLength={7}
value={displayHex}
onChange={(e) => {
const v = e.currentTarget.value;
setDraft(v);
if (isValidHex(v)) {
onChange(normalizeHex(v));
}
}}
onBlur={() => commitHex(draft || parsedValue)}
onKeyDown={(e) => {
if (e.key === "Enter") {
commitHex(draft || parsedValue);
setOpen(false);
}
}}
className="mt-3 border-border bg-secondary/30 font-mono text-sm uppercase"
placeholder="#000000"
/>
</PopoverContent>
</Popover>
);
},
);
ColorPicker.displayName = "ColorPicker";
export { ColorPicker };
+153
View File
@@ -0,0 +1,153 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+120
View File
@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]: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 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]: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 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+33
View File
@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]: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 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2 border-l border-l-transparent p-px",
orientation === "horizontal" &&
"h-2 flex-col border-t border-t-transparent p-px",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-[hsl(240_4%_32%)] transition-colors hover:bg-[hsl(240_4%_44%)]" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
+159
View File
@@ -0,0 +1,159 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]: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 origin-[--radix-select-content-transform-origin]",
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}
{...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)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
+138
View File
@@ -0,0 +1,138 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
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",
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",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+771
View File
@@ -0,0 +1,771 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeft } from "lucide-react"
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
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
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
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
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
)
SidebarProvider.displayName = "SidebarProvider"
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="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
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
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)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)}
/>
<div
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)_+_theme(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"
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"
>
{children}
</div>
</div>
</div>
)
}
)
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<button
ref={ref}
data-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 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[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 group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
})
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props}
/>
)
})
SidebarInset.displayName = "SidebarInset"
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props}
/>
)
})
SidebarInput.displayName = "SidebarInput"
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarFooter.displayName = "SidebarFooter"
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
})
SidebarSeparator.displayName = "SidebarSeparator"
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
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}
/>
)
})
SidebarContent.displayName = "SidebarContent"
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
})
SidebarGroup.displayName = "SidebarGroup"
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
<Comp
ref={ref}
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 outline-none ring-sidebar-ring 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}
/>
)
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring 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 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarGroupAction.displayName = "SidebarGroupAction"
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
))
SidebarGroupContent.displayName = "SidebarGroupContent"
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
))
SidebarMenu.displayName = "SidebarMenu"
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
))
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] 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 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 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 group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
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",
},
}
)
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
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>
)
}
)
SidebarMenuButton.displayName = "SidebarMenuButton"
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md: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 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props}
/>
)
})
SidebarMenuAction.displayName = "SidebarMenuAction"
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"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}
/>
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
ref={ref}
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>
)
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
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}
/>
))
SidebarMenuSub.displayName = "SidebarMenuSub"
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
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 outline-none ring-sidebar-ring 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}
/>
)
})
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
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,
}
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }
+25
View File
@@ -0,0 +1,25 @@
import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
function Toaster({ ...props }: ToasterProps) {
return (
<Sonner
theme="dark"
position="bottom-right"
richColors
closeButton
style={{ fontFamily: '"Poppins", system-ui, sans-serif' }}
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-card group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
}
export { Toaster };
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
+53
View File
@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }
+32
View File
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+124
View File
@@ -0,0 +1,124 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { api } from "../api/client";
import {
canEditConfig,
canViewConfig,
displayDiscordName,
hasPermission,
type DashboardPermissions,
type DashboardUser,
} from "../lib/permissions";
interface AuthContextValue {
user: DashboardUser | null;
permissions: DashboardPermissions | null;
isAuthenticated: boolean;
loading: boolean;
loginWithDiscord: () => void;
logout: () => Promise<void>;
refreshSession: () => Promise<boolean>;
hasPermission: (key: string) => boolean;
canViewConfig: (file: string) => boolean;
canEditConfig: (file: string) => boolean;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<DashboardUser | null>(null);
const [loading, setLoading] = useState(true);
const refreshSession = useCallback(async () => {
try {
const res = await api.me();
if (res.data) {
setUser(res.data);
return true;
}
setUser(null);
return false;
} catch {
setUser(null);
return false;
}
}, []);
useEffect(() => {
refreshSession().finally(() => setLoading(false));
}, [refreshSession]);
const loginWithDiscord = useCallback(() => {
window.location.href = "/api/auth/discord";
}, []);
const logout = useCallback(async () => {
try {
await api.logout();
} finally {
setUser(null);
}
}, []);
const permissions = user?.permissions ?? null;
const checkPermission = useCallback(
(key: string) => hasPermission(permissions, key),
[permissions],
);
const checkViewConfig = useCallback(
(file: string) => canViewConfig(permissions, file),
[permissions],
);
const checkEditConfig = useCallback(
(file: string) => canEditConfig(permissions, file),
[permissions],
);
const value = useMemo(
() => ({
user,
permissions,
isAuthenticated: !!user,
loading,
loginWithDiscord,
logout,
refreshSession,
hasPermission: checkPermission,
canViewConfig: checkViewConfig,
canEditConfig: checkEditConfig,
}),
[
user,
permissions,
loading,
loginWithDiscord,
logout,
refreshSession,
checkPermission,
checkViewConfig,
checkEditConfig,
],
);
return (
<AuthContext.Provider value={value}>{children}</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
export { displayDiscordName };
+64
View File
@@ -0,0 +1,64 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { api, type BotStats } from "@/api/client";
interface BotStatsContextValue {
stats: BotStats | null;
loading: boolean;
error: string;
refresh: () => Promise<void>;
}
const BotStatsContext = createContext<BotStatsContextValue | null>(null);
export function BotStatsProvider({ children }: { children: ReactNode }) {
const [stats, setStats] = useState<BotStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const refresh = useCallback(async () => {
try {
const res = await api.stats();
if (res.data) {
setStats(res.data);
setError("");
} else if (res.error) {
setError(res.error);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load stats");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
const id = setInterval(refresh, 30_000);
return () => clearInterval(id);
}, [refresh]);
const value = useMemo(
() => ({ stats, loading, error, refresh }),
[stats, loading, error, refresh],
);
return (
<BotStatsContext.Provider value={value}>{children}</BotStatsContext.Provider>
);
}
export function useBotStats() {
const ctx = useContext(BotStatsContext);
if (!ctx) {
throw new Error("useBotStats must be used within BotStatsProvider");
}
return ctx;
}
+72
View File
@@ -0,0 +1,72 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { api } from "@/api/client";
import { applyDashboardBranding } from "@/lib/apply-dashboard-branding";
import { DEFAULT_BRANDING, type DashboardBranding } from "@/lib/branding-types";
interface BrandingContextValue {
branding: DashboardBranding;
loading: boolean;
refreshBranding: () => Promise<void>;
setBrandingLocal: (next: DashboardBranding) => void;
}
const BrandingContext = createContext<BrandingContextValue | null>(null);
export function BrandingProvider({ children }: { children: ReactNode }) {
const [branding, setBranding] = useState<DashboardBranding>(DEFAULT_BRANDING);
const [loading, setLoading] = useState(true);
const refreshBranding = useCallback(async () => {
try {
const res = await api.getBranding();
if (res.data) {
setBranding(res.data);
applyDashboardBranding(res.data);
}
} catch {
setBranding(DEFAULT_BRANDING);
applyDashboardBranding(DEFAULT_BRANDING);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refreshBranding();
}, [refreshBranding]);
const setBrandingLocal = useCallback((next: DashboardBranding) => {
setBranding(next);
applyDashboardBranding(next);
}, []);
const value = useMemo(
() => ({
branding,
loading,
refreshBranding,
setBrandingLocal,
}),
[branding, loading, refreshBranding, setBrandingLocal],
);
return (
<BrandingContext.Provider value={value}>{children}</BrandingContext.Provider>
);
}
export function useBranding() {
const ctx = useContext(BrandingContext);
if (!ctx) {
throw new Error("useBranding must be used within BrandingProvider");
}
return ctx;
}
@@ -0,0 +1,101 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import {
api,
type DashboardNotification,
} from "@/api/client";
interface NotificationsContextValue {
items: DashboardNotification[];
unreadCount: number;
loading: boolean;
refresh: () => Promise<void>;
markAllRead: () => Promise<void>;
dismiss: (id: string) => Promise<void>;
}
const NotificationsContext = createContext<NotificationsContextValue | null>(
null,
);
export function NotificationsProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<DashboardNotification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const res = await api.getNotifications();
if (res.data) {
setItems(res.data.items);
setUnreadCount(res.data.unreadCount);
}
} catch {
/* ignore when logged out or API down */
} finally {
setLoading(false);
}
}, []);
const markAllRead = useCallback(async () => {
try {
const res = await api.markNotificationsRead("all");
setItems((prev) => prev.map((n) => ({ ...n, read: true })));
setUnreadCount(res.data?.unreadCount ?? 0);
} catch {
/* ignore */
}
}, []);
const dismiss = useCallback(
async (id: string) => {
try {
const res = await api.dismissNotification(id);
setItems((prev) => prev.filter((n) => n.id !== id));
setUnreadCount(res.data?.unreadCount ?? 0);
} catch {
/* ignore */
}
},
[],
);
useEffect(() => {
refresh();
const id = setInterval(refresh, 30_000);
return () => clearInterval(id);
}, [refresh]);
const value = useMemo(
() => ({
items,
unreadCount,
loading,
refresh,
markAllRead,
dismiss,
}),
[items, unreadCount, loading, refresh, markAllRead, dismiss],
);
return (
<NotificationsContext.Provider value={value}>
{children}
</NotificationsContext.Provider>
);
}
export function useNotifications() {
const ctx = useContext(NotificationsContext);
if (!ctx) {
throw new Error("useNotifications must be used within NotificationsProvider");
}
return ctx;
}
+61
View File
@@ -0,0 +1,61 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { api } from "@/api/client";
import type { SetupStatus } from "@/lib/setup-types";
interface SetupContextValue {
status: SetupStatus | null;
loading: boolean;
error: string;
complete: boolean;
refresh: () => Promise<void>;
}
const SetupContext = createContext<SetupContextValue | null>(null);
export function SetupProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<SetupStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const refresh = useCallback(async () => {
setError("");
try {
const res = await api.getSetupStatus();
setStatus(res.data ?? null);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not load setup status");
setStatus(null);
}
}, []);
useEffect(() => {
refresh().finally(() => setLoading(false));
}, [refresh]);
const value = useMemo(
() => ({
status,
loading,
error,
complete: Boolean(status?.complete),
refresh,
}),
[status, loading, error, refresh],
);
return <SetupContext.Provider value={value}>{children}</SetupContext.Provider>;
}
export function useSetup() {
const ctx = useContext(SetupContext);
if (!ctx) throw new Error("useSetup must be used within SetupProvider");
return ctx;
}
+26
View File
@@ -0,0 +1,26 @@
import { useCallback, useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import { api } from "@/api/client";
export function useAddonConfigs() {
const location = useLocation();
const [files, setFiles] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const res = await api.listAddonConfigs();
setFiles(res.data ?? []);
} catch {
setFiles([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
}, [refresh, location.pathname]);
return { files, loading, refresh };
}
+16
View File
@@ -0,0 +1,16 @@
import { useEffect, useRef, type ForwardedRef } from "react";
export function useForwardedRef<T>(forwardedRef: ForwardedRef<T>) {
const innerRef = useRef<T>(null);
useEffect(() => {
if (!forwardedRef) return;
if (typeof forwardedRef === "function") {
forwardedRef(innerRef.current);
} else {
forwardedRef.current = innerRef.current;
}
});
return innerRef;
}
+19
View File
@@ -0,0 +1,19 @@
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
}
+149
View File
@@ -0,0 +1,149 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 240 10% 4%;
--foreground: 0 0% 98%;
--card: 240 7% 11%;
--card-foreground: 0 0% 98%;
--popover: 240 8% 10%;
--popover-foreground: 0 0% 98%;
--primary: 141 45% 49%;
--primary-foreground: 0 0% 100%;
--secondary: 240 6% 14%;
--secondary-foreground: 0 0% 90%;
--muted: 240 6% 14%;
--muted-foreground: 240 5% 55%;
--accent: 141 29% 18%;
--accent-foreground: 141 52% 78%;
--destructive: 0 72% 55%;
--destructive-foreground: 0 0% 100%;
--border: 240 6% 18%;
--input: 240 6% 14%;
--ring: 141 45% 49%;
--radius: 0.75rem;
--chart-1: 141 45% 49%;
--chart-2: 199 89% 48%;
--chart-3: 38 92% 50%;
--chart-4: 280 65% 60%;
--chart-5: 0 72% 55%;
--sidebar-background: 240 10% 5%;
--sidebar-foreground: 240 5% 90%;
--sidebar-primary: 141 45% 49%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 141 26% 14%;
--sidebar-accent-foreground: 141 48% 82%;
--sidebar-border: 240 6% 14%;
--sidebar-ring: 141 45% 49%;
--scrollbar-size: 6px;
--scrollbar-thumb: 240 4% 32%;
--scrollbar-thumb-hover: 240 4% 44%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background font-sans text-foreground antialiased;
}
html {
color-scheme: dark;
}
html,
body,
* {
scrollbar-width: thin;
scrollbar-color: hsl(var(--scrollbar-thumb)) transparent;
}
*::-webkit-scrollbar {
width: var(--scrollbar-size);
height: var(--scrollbar-size);
}
*::-webkit-scrollbar-button {
display: none;
width: 0;
height: 0;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: hsl(var(--scrollbar-thumb));
border-radius: 9999px;
border: 2px solid transparent;
background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--scrollbar-thumb-hover));
}
*::-webkit-scrollbar-thumb:active {
background-color: hsl(var(--scrollbar-thumb-hover));
}
*::-webkit-scrollbar-corner {
background: transparent;
}
}
@layer utilities {
.dashboard-glow {
background: hsl(var(--background));
}
.promo-gradient {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
}
.overview-card-glow {
box-shadow: none;
}
.color-picker-swatch.react-colorful {
width: 220px;
height: 140px;
}
.color-picker-swatch .react-colorful__saturation {
border-radius: calc(var(--radius) - 2px) calc(var(--radius) - 2px) 0 0;
}
.color-picker-swatch .react-colorful__hue {
height: 12px;
border-radius: 0 0 calc(var(--radius) - 2px) calc(var(--radius) - 2px);
margin-top: 10px;
}
.color-picker-swatch .react-colorful__pointer {
width: 16px;
height: 16px;
}
.unicode-emoji-panel-host emoji-picker.unicode-emoji-picker-el {
width: 100%;
height: min(50vh, 380px);
--background: hsl(var(--card));
--border-color: hsl(var(--border));
--button-active-background: hsl(var(--primary) / 0.25);
--button-hover-background: hsl(var(--accent));
--input-border-color: hsl(var(--border));
--input-font-color: hsl(var(--foreground));
--input-placeholder-color: hsl(var(--muted-foreground));
--outline-color: hsl(var(--ring));
--category-font-color: hsl(var(--muted-foreground));
--indicator-color: hsl(var(--primary));
}
}
+128
View File
@@ -0,0 +1,128 @@
import { isValidHex, normalizeHex } from "@/lib/normalize-hex";
export const ACCENT_STORAGE_KEY = "supportbot-dashboard-accent";
export const DEFAULT_ACCENT_HEX = "#45b46c";
const CSS_VARS = [
"--primary",
"--ring",
"--chart-1",
"--sidebar-primary",
"--sidebar-ring",
"--accent",
"--accent-foreground",
"--sidebar-accent",
"--sidebar-accent-foreground",
] as const;
export function hexToHsl(hex: string): { h: number; s: number; l: number } {
const normalized = normalizeHex(hex, DEFAULT_ACCENT_HEX);
const r = parseInt(normalized.slice(1, 3), 16) / 255;
const g = parseInt(normalized.slice(3, 5), 16) / 255;
const b = parseInt(normalized.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
let h = 0;
let s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
default:
h = ((r - g) / d + 4) / 6;
break;
}
}
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100),
};
}
function hsl(h: number, s: number, l: number): string {
return `${h} ${s}% ${l}%`;
}
/** Apply accent colour to dashboard CSS variables (replaces default purple). */
export function applyAccentTheme(hex: string): string {
const normalized = normalizeHex(hex, DEFAULT_ACCENT_HEX);
const { h, s, l } = hexToHsl(normalized);
const root = document.documentElement;
const primaryS = Math.min(Math.max(s, 45), 85);
const primaryL = Math.min(Math.max(l, 42), 62);
root.style.setProperty("--primary", hsl(h, primaryS, primaryL));
root.style.setProperty("--ring", hsl(h, primaryS, primaryL));
root.style.setProperty("--chart-1", hsl(h, primaryS, primaryL));
root.style.setProperty("--sidebar-primary", hsl(h, primaryS, primaryL));
root.style.setProperty("--sidebar-ring", hsl(h, primaryS, primaryL));
root.style.setProperty("--primary-foreground", "0 0% 100%");
root.style.setProperty("--sidebar-primary-foreground", "0 0% 100%");
root.style.setProperty("--accent", hsl(h, Math.round(primaryS * 0.64), 18));
root.style.setProperty(
"--accent-foreground",
hsl(h, Math.min(Math.round(primaryS * 1.15), 90), 78),
);
root.style.setProperty("--sidebar-accent", hsl(h, Math.round(primaryS * 0.57), 14));
root.style.setProperty(
"--sidebar-accent-foreground",
hsl(h, Math.min(Math.round(primaryS * 1.07), 88), 82),
);
return normalized;
}
export function clearAccentTheme(): void {
for (const name of CSS_VARS) {
document.documentElement.style.removeProperty(name);
}
document.documentElement.style.removeProperty("--primary-foreground");
document.documentElement.style.removeProperty("--sidebar-primary-foreground");
}
export function getStoredAccentHex(): string | null {
try {
const raw = localStorage.getItem(ACCENT_STORAGE_KEY);
if (!raw || !isValidHex(raw)) return null;
return normalizeHex(raw);
} catch {
return null;
}
}
export function saveAccentTheme(hex: string): string {
const normalized = applyAccentTheme(hex);
try {
localStorage.setItem(ACCENT_STORAGE_KEY, normalized);
} catch {
/* ignore quota */
}
return normalized;
}
export function resetAccentTheme(): void {
try {
localStorage.removeItem(ACCENT_STORAGE_KEY);
} catch {
/* ignore */
}
clearAccentTheme();
}
export function initAccentTheme(): void {
const stored = getStoredAccentHex();
if (stored) applyAccentTheme(stored);
}
+25
View File
@@ -0,0 +1,25 @@
export const ADDON_CONFIG_PREFIX = "addon:";
export function addonConfigFileParam(filename: string): string {
return `${ADDON_CONFIG_PREFIX}${filename}`;
}
export function parseConfigFileParam(
file: string | null,
): { type: "main" | "addon"; file: string } | null {
if (!file) return null;
if (file.startsWith(ADDON_CONFIG_PREFIX)) {
return {
type: "addon",
file: file.slice(ADDON_CONFIG_PREFIX.length),
};
}
return { type: "main", file };
}
export function addonConfigLabel(filename: string): string {
return filename
.replace(/\.(yml|yaml|json)$/i, "")
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
@@ -0,0 +1,23 @@
import type { DashboardBranding } from "@/lib/branding-types";
const FAVICON_LINK_ID = "supportbot-dashboard-favicon";
export function applyDashboardBranding(branding: DashboardBranding) {
if (branding.pageTitle) {
document.title = branding.pageTitle;
}
let link = document.getElementById(FAVICON_LINK_ID) as HTMLLinkElement | null;
if (branding.faviconUrl) {
const href = `${branding.faviconUrl}${branding.updatedAt ? `?v=${branding.updatedAt}` : ""}`;
if (!link) {
link = document.createElement("link");
link.id = FAVICON_LINK_ID;
link.rel = "icon";
document.head.appendChild(link);
}
link.href = href;
} else if (link) {
link.remove();
}
}
+15
View File
@@ -0,0 +1,15 @@
export interface DashboardBranding {
title: string;
pageTitle: string;
hasFavicon: boolean;
faviconUrl: string | null;
updatedAt: number | null;
}
export const DEFAULT_BRANDING: DashboardBranding = {
title: "SupportBot",
pageTitle: "SupportBot Dashboard",
hasFavicon: false,
faviconUrl: null,
updatedAt: null,
};
+29
View File
@@ -0,0 +1,29 @@
import type { ConfigSchema } from "./types";
import { messagesSchema } from "./messages";
import { supportbotSchema } from "./supportbot";
import { supportbotAiSchema } from "./supportbot-ai";
import { ticketPanelSchema } from "./ticket-panel";
export type ConfigFileName =
| "supportbot"
| "ticket-panel"
| "commands"
| "messages"
| "supportbot-ai";
export const configSchemas: Record<ConfigFileName, ConfigSchema | "commands"> = {
supportbot: supportbotSchema,
"ticket-panel": ticketPanelSchema,
commands: "commands",
messages: messagesSchema,
"supportbot-ai": supportbotAiSchema,
};
export function getSchema(file: string): ConfigSchema | "commands" | null {
if (file in configSchemas) {
return configSchemas[file as ConfigFileName];
}
return null;
}
export * from "./types";
@@ -0,0 +1,41 @@
import type { ConfigSchema } from "./types";
export const messagesSchema: ConfigSchema = [
{
title: "Info command",
fields: [
{ path: "Info.Title", label: "Title", type: "text" },
{ path: "Info.Description", label: "Description", type: "textarea" },
{ path: "Info.Button", label: "Button label", type: "text" },
{ path: "Info.URL", label: "Button URL", type: "text" },
{ path: "Info.Colour", label: "Colour", type: "color" },
],
},
{
title: "Welcome message",
fields: [
{ path: "Welcome.Enabled", label: "Enabled", type: "boolean" },
{
path: "Welcome.LayoutStyle",
label: "Layout style",
type: "select",
options: [
{ value: "modern", label: "Modern (components)" },
{ value: "classic", label: "Classic embed" },
],
},
{ path: "Welcome.Embed.Colour", label: "Embed colour", type: "color" },
{ path: "Welcome.Embed.Title", label: "Embed title", type: "text" },
{ path: "Welcome.Embed.Message", label: "Embed message", type: "textarea" },
],
},
{
title: "Leave message",
fields: [
{ path: "Leave.Enabled", label: "Enabled", type: "boolean" },
{ path: "Leave.Embed.Colour", label: "Embed colour", type: "color" },
{ path: "Leave.Embed.Title", label: "Embed title", type: "text" },
{ path: "Leave.Embed.Message", label: "Embed message", type: "textarea" },
],
},
];
@@ -0,0 +1,142 @@
import type { ConfigSchema } from "./types";
const providers = [
{ value: "openai", label: "OpenAI" },
{ value: "groq", label: "Groq" },
{ value: "claude", label: "Claude" },
];
const messageTypes = [
{ value: "text", label: "Plain text" },
{ value: "embed", label: "Embed" },
{ value: "components_v2", label: "Components v2" },
];
const memoryScopes = [
{ value: "channel", label: "Per channel" },
{ value: "guild", label: "Per server" },
];
export const supportbotAiSchema: ConfigSchema = [
{
title: "General",
fields: [
{ path: "Enabled", label: "AI enabled", type: "boolean" },
{
path: "General.Provider",
label: "Provider",
type: "select",
options: providers,
},
{ path: "General.Model", label: "Model", type: "text" },
{
path: "General.Model_API_Key",
label: "API key",
type: "secret",
placeholder: "MODEL_API_KEY",
},
{ path: "General.Tokens", label: "Max tokens", type: "number" },
{
path: "General.Temperature",
label: "Temperature",
type: "number",
},
{ path: "General.Name", label: "Display name", type: "text" },
{
path: "General.PastebinAPI_Key",
label: "Pastebin API key",
type: "secret",
placeholder: "PASTEBIN_API_KEY",
},
{
path: "General.PastebinAPI_URL",
label: "Pastebin API URL",
type: "text",
},
],
},
{
title: "Style & channel",
fields: [
{
path: "Style.MessageType",
label: "Message type",
type: "select",
options: messageTypes,
},
{
path: "Features.SuggestTickets",
label: "Suggest tickets",
type: "boolean",
},
{
path: "Channels.AIChannel",
label: "AI channel ID",
type: "discordId",
},
{ path: "Embed.Color", label: "Embed colour", type: "color" },
{ path: "Embed.Footer", label: "Embed footer", type: "text" },
],
},
{
title: "Memory",
fields: [
{ path: "Memory.Enabled", label: "Memory enabled", type: "boolean" },
{ path: "Memory.StoreFacts", label: "Store facts", type: "boolean" },
{
path: "Memory.StoreSummaries",
label: "Store summaries",
type: "boolean",
},
{
path: "Memory.MaxRecentMessages",
label: "Max recent messages",
type: "number",
},
{
path: "Memory.Scope",
label: "Memory scope",
type: "select",
options: memoryScopes,
},
],
},
{
title: "Custom instructions",
fields: [
{
path: "CustomInstructions",
label: "Instruction lines",
type: "stringList",
description: "One instruction per line sent to the AI.",
},
],
},
{
title: "Messages",
fields: [
{
path: "Messages.ErrorResponse",
label: "Error response",
type: "text",
},
{ path: "Messages.AIError", label: "AI error", type: "text" },
{ path: "Messages.AIDisabled", label: "AI disabled", type: "text" },
{
path: "Messages.MessageTooLong",
label: "Message too long",
type: "text",
},
{
path: "Messages.SuggestTicketText",
label: "Suggest ticket text",
type: "text",
},
{
path: "Messages.SuggestTicketReaction",
label: "Suggest ticket emoji",
type: "emoji",
},
],
},
];
@@ -0,0 +1,507 @@
import type { ConfigSchema } from "./types";
const activityTypes = [
{ value: "Playing", label: "Playing" },
{ value: "Watching", label: "Watching" },
{ value: "Listening", label: "Listening" },
{ value: "Competing", label: "Competing" },
{ value: "Streaming", label: "Streaming" },
];
const suggestionModes = [
{ value: "Channel", label: "Channel" },
{ value: "Forum", label: "Forum" },
];
const ticketTypes = [
{ value: "channels", label: "Channels" },
{ value: "threads", label: "Threads" },
];
const ticketSubjects = [
{ value: "description", label: "Channel description" },
{ value: "embed", label: "Embed" },
];
const buttonStyles = [
{ value: "1", label: "Blurple" },
{ value: "2", label: "Grey" },
{ value: "3", label: "Green" },
{ value: "4", label: "Red" },
];
export const supportbotSchema: ConfigSchema = [
{
title: "General",
fields: [
{ path: "General.Name", label: "Bot name", type: "text" },
{
path: "General.Token",
label: "Bot token",
type: "secret",
description: "Masked in the dashboard. Your real token is preserved on save.",
},
{
path: "General.GuildId",
label: "Server (guild) ID",
type: "discordId",
description:
"Right-click your Discord server icon → Copy Server ID. The bot should only stay in this server.",
placeholder: "Discord server snowflake ID",
},
{
path: "General.Addons.Enabled",
label: "Addons enabled",
type: "boolean",
},
],
},
{
title: "Activity",
fields: [
{ path: "Activity.Status", label: "Status text", type: "text" },
{
path: "Activity.Type",
label: "Activity type",
type: "select",
options: activityTypes,
},
{
path: "Activity.StreamingURL",
label: "Streaming URL",
type: "text",
description: "Required when type is Streaming.",
},
],
},
{
title: "Embeds",
fields: [
{ path: "Embed.Colours.General", label: "General colour", type: "color" },
{ path: "Embed.Colours.Success", label: "Success colour", type: "color" },
{ path: "Embed.Colours.Error", label: "Error colour", type: "color" },
{ path: "Embed.Colours.Warn", label: "Warn colour", type: "color" },
{ path: "Embed.Footer", label: "Footer text", type: "text" },
],
},
{
title: "Roles",
fields: [
{
path: "Roles.AutoRole.Enabled",
label: "Auto role enabled",
type: "boolean",
},
{
path: "Roles.AutoRole.Role",
label: "Auto role",
type: "discordRole",
},
{
path: "Roles.StaffMember.Staff",
label: "Staff role",
type: "discordRole",
},
{
path: "Roles.StaffMember.Admin",
label: "Admin role",
type: "discordRole",
},
{
path: "Roles.StaffMember.Moderator",
label: "Moderator role",
type: "discordRole",
},
{
path: "Roles.Mod.AllowSupportStaff",
label: "Allow support staff /mod",
type: "boolean",
},
],
},
{
title: "Welcome & leave",
fields: [
{ path: "Welcome.Enabled", label: "Welcome enabled", type: "boolean" },
{
path: "Welcome.Channel",
label: "Welcome channel",
type: "discordChannel",
},
{ path: "Leave.Enabled", label: "Leave enabled", type: "boolean" },
{
path: "Leave.Channel",
label: "Leave channel",
type: "discordChannel",
},
],
},
{
title: "Suggestions",
fields: [
{
path: "Suggestions.Mode",
label: "Mode",
type: "select",
options: suggestionModes,
},
{
path: "Suggestions.Channel",
label: "Suggestions channel",
type: "discordChannel",
},
{
path: "Suggestions.ForumChannel",
label: "Forum channel",
type: "discordChannel",
},
{
path: "Suggestions.UpvoteEmoji",
label: "Upvote emoji",
type: "emoji",
},
{
path: "Suggestions.DownvoteEmoji",
label: "Downvote emoji",
type: "emoji",
},
{ path: "Suggestions.OwnSuggestion", label: "Own suggestion votes", type: "boolean" },
{ path: "Suggestions.ShowUsers", label: "Show users", type: "boolean" },
{
path: "Suggestions.UpvoteTitle",
label: "Upvote button title",
type: "text",
},
{
path: "Suggestions.DownvoteTitle",
label: "Downvote button title",
type: "text",
},
{
path: "Suggestions.Threads.Enabled",
label: "Suggestion threads",
type: "boolean",
},
{
path: "Suggestions.Threads.Title",
label: "Thread title",
type: "text",
},
{
path: "Suggestions.Threads.Reason",
label: "Thread reason",
type: "text",
},
{
path: "Suggestions.Buttons.Upvote",
label: "Upvote button style",
type: "select",
options: buttonStyles,
},
{
path: "Suggestions.Buttons.Downvote",
label: "Downvote button style",
type: "select",
options: buttonStyles,
},
{
path: "Suggestions.Buttons.RemoveVote",
label: "Remove vote button style",
type: "select",
options: buttonStyles,
},
{
path: "Suggestions.Buttons.RemoveVote_Title",
label: "Remove vote title",
type: "text",
},
{
path: "Suggestions.Tags.Created",
label: "Tag: created",
type: "text",
},
{
path: "Suggestions.Tags.Considering",
label: "Tag: considering",
type: "text",
},
{
path: "Suggestions.Tags.Solved",
label: "Tag: solved",
type: "text",
},
{
path: "Suggestions.Tags.Denied",
label: "Tag: denied",
type: "text",
},
],
},
{
title: "Tickets",
fields: [
{
path: "Ticket.TicketType",
label: "Ticket type",
type: "select",
options: ticketTypes,
},
{
path: "Ticket.UserManagement.DMOnAdd",
label: "DM when added to ticket",
type: "boolean",
},
{
path: "Ticket.UserManagement.DMOnRemove",
label: "DM when removed from ticket",
type: "boolean",
},
{
path: "Ticket.UserManagement.DMTranscript",
label: "DM transcript on close",
type: "boolean",
},
{
path: "Ticket.TicketHome",
label: "Ticket home channel",
type: "discordChannel",
},
{
path: "Ticket.TicketChannelsCategory",
label: "Ticket category",
type: "discordCategory",
},
{
path: "Ticket.TicketChannelsCategory2",
label: "Overflow ticket category",
type: "discordCategory",
description: "Used when the first category is full.",
},
{
path: "Ticket.TicketArchiveCategory",
label: "Archive category",
type: "discordCategory",
},
{ path: "Ticket.Channel", label: "Channel prefix", type: "text" },
{
path: "Ticket.TicketSubject",
label: "Ticket subject display",
type: "select",
options: ticketSubjects,
},
{ path: "Ticket.Timeout", label: "Close timeout (minutes)", type: "number" },
{
path: "Ticket.TicketsPerUser",
label: "Tickets per user",
type: "number",
},
{ path: "Ticket.TicketReason", label: "Require reason", type: "boolean" },
{
path: "Ticket.Close.StaffOnly",
label: "Close staff only",
type: "boolean",
},
{
path: "Ticket.Invites.StaffOnly",
label: "Ticket invites staff only",
type: "boolean",
},
{
path: "Ticket.ClaimTickets.Enabled",
label: "Claim tickets",
type: "boolean",
},
{
path: "Ticket.ClaimTickets.Channel",
label: "Claim log channel",
type: "discordChannel",
},
{
path: "Ticket.ClaimTickets.ClaimAcceptTime",
label: "Claim accept time (ms)",
type: "number",
},
{
path: "Ticket.ClaimTickets.ButtonTitle",
label: "Claim button title",
type: "text",
},
{
path: "Ticket.ClaimTickets.ButtonEmoji",
label: "Claim button emoji",
type: "emoji",
},
{
path: "Ticket.ClaimTickets.Button",
label: "Claim button style",
type: "select",
options: buttonStyles,
},
{
path: "Ticket.ReviewSystem.Enabled",
label: "Review system",
type: "boolean",
},
{
path: "Ticket.ReviewSystem.Channel",
label: "Review channel",
type: "discordChannel",
},
{
path: "Ticket.ReviewSystem.UseModal",
label: "Review uses modal",
type: "boolean",
},
{
path: "Ticket.Log.TicketDataLog",
label: "Ticket data log channel",
type: "discordChannel",
},
{
path: "Ticket.Log.TickeDataTitle",
label: "Ticket data log title",
type: "text",
},
{
path: "Ticket.Log.TicketBlacklistLog",
label: "Blacklist log channel",
type: "discordChannel",
},
{
path: "Ticket.AIMode.Enabled",
label: "AI mode in tickets",
type: "boolean",
},
{
path: "Ticket.AIMode.ThreadName",
label: "AI thread name",
type: "text",
},
{
path: "Ticket.AIMode.WelcomeMessage",
label: "AI welcome message",
type: "textarea",
},
{
path: "Ticket.DepartmentSystem.Enabled",
label: "Departments enabled",
type: "boolean",
},
{
path: "Ticket.DepartmentSystem.Placeholder",
label: "Department placeholder",
type: "text",
},
{
path: "Ticket.DepartmentSystem.DefaultDepartment",
label: "Default department key",
type: "text",
},
{
path: "Ticket.PrioritySystem.Enabled",
label: "Priority system",
type: "boolean",
},
{
path: "Ticket.PrioritySystem.DefaultPriority",
label: "Default priority",
type: "text",
},
{
path: "Ticket.PrioritySystem.AllowUsersToChooseOnOpen",
label: "User picks priority on open",
type: "boolean",
},
{
path: "Ticket.Questions.Enabled",
label: "Ticket questions",
type: "boolean",
},
{
path: "Ticket.Questions.List",
label: "Default questions",
type: "stringList",
},
],
},
{
title: "Voice tickets",
fields: [
{ path: "VoiceTickets.Name", label: "VC name format", type: "text" },
{
path: "VoiceTickets.Category",
label: "VC category",
type: "discordCategory",
},
],
},
{
title: "Logging",
fields: [
{
path: "MessageDelete.Channel",
label: "Message delete log channel",
type: "discordChannel",
},
{
path: "MessageDelete.Colour",
label: "Message delete embed colour",
type: "color",
},
{
path: "MessageUpdate.Channel",
label: "Message update log channel",
type: "discordChannel",
},
{
path: "MessageUpdate.Colour",
label: "Message update embed colour",
type: "color",
},
{
path: "Translate.TranslateLog",
label: "Translate log channel",
type: "discordChannel",
},
],
},
{
title: "Buttons & menus",
description: "Ticket panel and control button labels and styles.",
fields: [
{ path: "Buttons.General.Delete", label: "Delete emoji", type: "emoji" },
{
path: "Buttons.General.DeleteStyle",
label: "Delete button style",
type: "select",
options: buttonStyles,
},
{
path: "Buttons.Voice.TicketDeleteText",
label: "Voice delete label",
type: "text",
},
{
path: "Buttons.Tickets.ClaimEmoji",
label: "Ticket claim emoji",
type: "emoji",
},
{
path: "Buttons.Tickets.ClaimStyle",
label: "Ticket claim style",
type: "select",
options: buttonStyles,
},
{
path: "SelectMenus.Tickets.PanelEmoji",
label: "Panel menu emoji",
type: "emoji",
},
{
path: "SelectMenus.Tickets.CloseEmoji",
label: "Close menu emoji",
type: "emoji",
},
],
},
];
@@ -0,0 +1,109 @@
import type { ConfigSchema } from "./types";
const buttonStyles = [
{ value: "1", label: "Blurple" },
{ value: "2", label: "Grey" },
{ value: "3", label: "Green" },
{ value: "4", label: "Red" },
];
const layouts = [
{ value: "1", label: "Style 1" },
{ value: "2", label: "Style 2" },
{ value: "3", label: "Style 3" },
{ value: "4", label: "Style 4 (classic embed)" },
];
export const ticketPanelSchema: ConfigSchema = [
{
title: "Panel",
fields: [{ path: "Panel", label: "Panel enabled", type: "boolean" }],
},
{
title: "Embed style",
fields: [
{ path: "Style.Title", label: "Title", type: "text" },
{ path: "Style.Description", label: "Description", type: "textarea" },
{ path: "Style.Image", label: "Image URL", type: "text" },
{ path: "Style.Color", label: "Embed colour", type: "color" },
{
path: "Style.Layout",
label: "Layout",
type: "select",
options: layouts,
},
],
},
{
title: "Create button",
fields: [
{ path: "Button.Text", label: "Button text", type: "text" },
{ path: "Button.Emoji", label: "Button emoji", type: "emoji" },
{
path: "Button.Color",
label: "Button style",
type: "select",
options: buttonStyles,
},
{ path: "Button.CustomId", label: "Custom ID", type: "text" },
],
},
{
title: "Department menu",
fields: [
{ path: "DepartmentMenu.CustomId", label: "Custom ID", type: "text" },
{ path: "DepartmentMenu.Placeholder", label: "Placeholder", type: "text" },
{
path: "DepartmentMenu.DefaultDescription",
label: "Default description",
type: "text",
},
{
path: "DepartmentMenu.DefaultEmoji",
label: "Default emoji",
type: "emoji",
},
],
},
{
title: "Panel settings",
fields: [
{ path: "Settings.Image", label: "Show image", type: "boolean" },
{ path: "Settings.TopTitle", label: "Top title", type: "boolean" },
{ path: "Settings.TopDivider", label: "Top divider", type: "boolean" },
{
path: "Settings.BottomTitle",
label: "Bottom title",
type: "boolean",
},
{
path: "Settings.BottomDivider",
label: "Bottom divider",
type: "boolean",
},
],
},
{
title: "Messages",
description: "Bot messages when using the ticket panel command.",
fields: [
{
path: "Messages.NotConfigured",
label: "Not configured",
type: "textarea",
},
{
path: "Messages.InvalidChannel",
label: "Invalid channel",
type: "textarea",
},
{ path: "Messages.PanelSent", label: "Panel sent", type: "text" },
{
path: "Messages.PanelSentWithDepartments",
label: "Panel sent (departments)",
type: "text",
},
{ path: "Messages.SendError", label: "Send error", type: "text" },
],
},
];
+32
View File
@@ -0,0 +1,32 @@
export type FieldType =
| "text"
| "textarea"
| "boolean"
| "number"
| "color"
| "emoji"
| "select"
| "discordId"
| "discordChannel"
| "discordRole"
| "discordCategory"
| "secret"
| "stringList"
| "googleFont";
export interface ConfigField {
path: string;
label: string;
type: FieldType;
description?: string;
options?: { value: string; label: string }[];
placeholder?: string;
}
export interface ConfigSection {
title: string;
description?: string;
fields: ConfigField[];
}
export type ConfigSchema = ConfigSection[];
+57
View File
@@ -0,0 +1,57 @@
export function getByPath(obj: Record<string, unknown>, path: string): unknown {
const keys = path.split(".");
let cur: unknown = obj;
for (const key of keys) {
if (cur == null || typeof cur !== "object") return undefined;
cur = (cur as Record<string, unknown>)[key];
}
return cur;
}
export function setByPath(
obj: Record<string, unknown>,
path: string,
value: unknown,
): Record<string, unknown> {
const keys = path.split(".");
const clone = structuredClone(obj);
let cur: Record<string, unknown> = clone;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (cur[key] == null || typeof cur[key] !== "object") {
cur[key] = {};
}
cur = cur[key] as Record<string, unknown>;
}
cur[keys[keys.length - 1]] = value;
return clone;
}
export function flattenUpdates(
configFile: string,
obj: Record<string, unknown>,
prefix = "",
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, val] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${key}` : key;
if (
val !== null &&
typeof val === "object" &&
!Array.isArray(val) &&
Object.keys(val as object).length > 0 &&
!isLeafObject(val as Record<string, unknown>)
) {
Object.assign(out, flattenUpdates(configFile, val as Record<string, unknown>, path));
} else {
out[`${configFile}:${path}`] = val;
}
}
return out;
}
function isLeafObject(o: Record<string, unknown>): boolean {
return Object.values(o).every(
(v) => v === null || typeof v !== "object" || Array.isArray(v),
);
}
@@ -0,0 +1,82 @@
import { api } from "@/api/client";
import type {
ClientAlertProvider,
DashboardAlertPayload,
DashboardAlertSeverity,
} from "./types";
const SEVERITY_ORDER: Record<DashboardAlertSeverity, number> = {
error: 0,
warning: 1,
info: 2,
};
function sortAlerts(alerts: DashboardAlertPayload[]) {
return [...alerts].sort((a, b) => {
const sa = SEVERITY_ORDER[a.severity] ?? 9;
const sb = SEVERITY_ORDER[b.severity] ?? 9;
if (sa !== sb) return sa - sb;
return a.title.localeCompare(b.title);
});
}
function dedupeAlerts(alerts: DashboardAlertPayload[]) {
const seen = new Set<string>();
return alerts.filter((alert) => {
if (seen.has(alert.id)) return false;
seen.add(alert.id);
return true;
});
}
/**
* Client-side alert registry. Server alerts come from GET /api/alerts;
* register providers here for browser-only checks (feature flags, UI hints, etc.).
*
* Example:
* dashboardAlertBuilder.register({
* id: "local-hint",
* evaluate: () => [{
* id: "local:hint",
* severity: "info",
* category: "local",
* title: "Tip",
* message: "You can customise accent colour in Settings.",
* }],
* });
*/
class DashboardAlertBuilder {
private clientProviders = new Map<string, ClientAlertProvider>();
register(provider: ClientAlertProvider) {
this.clientProviders.set(provider.id, provider);
return this;
}
unregister(id: string) {
this.clientProviders.delete(id);
}
private async evaluateClientProviders() {
const alerts: DashboardAlertPayload[] = [];
for (const provider of this.clientProviders.values()) {
const result = await provider.evaluate();
if (Array.isArray(result) && result.length) {
alerts.push(...result);
}
}
return alerts;
}
async loadAll(): Promise<DashboardAlertPayload[]> {
const [serverRes, clientAlerts] = await Promise.all([
api.getAlerts().catch(() => ({ data: [] as DashboardAlertPayload[] })),
this.evaluateClientProviders(),
]);
const serverAlerts = serverRes.data ?? [];
return sortAlerts(dedupeAlerts([...serverAlerts, ...clientAlerts]));
}
}
export const dashboardAlertBuilder = new DashboardAlertBuilder();
@@ -0,0 +1,12 @@
export { dashboardAlertBuilder } from "./builder";
export type {
ClientAlertProvider,
DashboardAlertAction,
DashboardAlertDefinition,
DashboardAlertGuildMeta,
DashboardAlertModuleMeta,
DashboardAlertPayload,
DashboardAlertRenderContext,
DashboardAlertSeverity,
} from "./types";
export { SEVERITY_STYLES } from "./types";
@@ -0,0 +1,75 @@
import type { ReactNode } from "react";
import type { GuildHealthStatus, GuildSummary } from "@/api/client";
export type DashboardAlertSeverity = "error" | "warning" | "info";
export interface DashboardAlertAction {
label: string;
href: string;
}
export interface DashboardAlertGuildMeta {
status?: GuildHealthStatus;
configuredGuildId?: string | null;
configuredGuild?: GuildSummary | null;
extraGuilds?: GuildSummary[];
}
export interface DashboardAlertModuleMeta {
moduleId?: string;
status?: "todo" | "progress" | "done";
passed?: number;
total?: number;
progress?: string | null;
}
export interface DashboardAlertPayload {
id: string;
severity: DashboardAlertSeverity;
category: string;
title: string;
message: string;
action?: DashboardAlertAction;
meta?: DashboardAlertGuildMeta & DashboardAlertModuleMeta & Record<string, unknown>;
}
export interface DashboardAlertRenderContext {
canLeaveGuild: boolean;
onLeaveGuild: (guildId: string) => Promise<void>;
leavingGuildId: string | null;
actionError: string;
}
export interface ClientAlertProvider {
id: string;
evaluate: () => DashboardAlertPayload[] | Promise<DashboardAlertPayload[]>;
}
export interface DashboardAlertDefinition extends DashboardAlertPayload {
/** Optional custom body rendered below the message */
body?: ReactNode;
}
export const SEVERITY_STYLES: Record<
DashboardAlertSeverity,
{ border: string; bg: string; icon: string; title: string }
> = {
error: {
border: "border-destructive/40",
bg: "bg-destructive/10",
icon: "text-destructive",
title: "text-destructive",
},
warning: {
border: "border-amber-500/40",
bg: "bg-amber-500/10",
icon: "text-amber-500",
title: "text-amber-200",
},
info: {
border: "border-sky-500/40",
bg: "bg-sky-500/10",
icon: "text-sky-400",
title: "text-sky-100",
},
};
+87
View File
@@ -0,0 +1,87 @@
/** Discord custom emoji mention: <:name:id> or <a:name:id> */
const CUSTOM_EMOJI_RE = /^<(a?):([^:>]+):(\d{17,20})>$/;
export interface GuildEmojiOption {
id: string;
name: string;
animated: boolean;
url: string | null;
}
export interface ParsedEmoji {
kind: "unicode" | "custom" | "empty";
/** Value stored in YAML */
value: string;
name?: string;
id?: string;
animated?: boolean;
}
export function formatCustomEmoji(
name: string,
id: string,
animated = false,
): string {
const prefix = animated ? "<a" : "<";
return `${prefix}:${name}:${id}>`;
}
export function parseEmojiValue(raw: unknown): ParsedEmoji {
const value = String(raw ?? "").trim();
if (!value) {
return { kind: "empty", value: "" };
}
const match = value.match(CUSTOM_EMOJI_RE);
if (match) {
return {
kind: "custom",
value,
animated: match[1] === "a",
name: match[2],
id: match[3],
};
}
return { kind: "unicode", value };
}
export function emojiCdnUrl(id: string, animated = false): string {
return `https://cdn.discordapp.com/emojis/${id}.${animated ? "gif" : "png"}?size=64`;
}
export function resolveEmojiPreview(
value: unknown,
guildEmojis: GuildEmojiOption[] = [],
): ParsedEmoji {
const parsed = parseEmojiValue(value);
if (parsed.kind === "custom" && parsed.id && !parsed.name) {
const found = guildEmojis.find((e) => e.id === parsed.id);
if (found) {
return {
kind: "custom",
value: formatCustomEmoji(found.name, found.id, found.animated),
name: found.name,
id: found.id,
animated: found.animated,
};
}
}
if (parsed.kind === "custom" && parsed.id) {
const found = guildEmojis.find((e) => e.id === parsed.id);
if (found && parsed.value !== formatCustomEmoji(found.name, found.id, found.animated)) {
return {
kind: "custom",
value: formatCustomEmoji(found.name, found.id, found.animated),
name: found.name,
id: found.id,
animated: found.animated,
};
}
}
return parsed;
}
export function isEmojiFieldKey(key: string): boolean {
return /emoji/i.test(key);
}
+10
View File
@@ -0,0 +1,10 @@
export function formatLogTime(iso: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
@@ -0,0 +1,90 @@
import type { ConfigField, ConfigSchema, FieldType } from "@/lib/config-schemas";
function labelFromKey(key: string): string {
return key
.replace(/_/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
function inferFieldType(key: string, value: unknown): FieldType {
if (typeof value === "boolean") return "boolean";
if (typeof value === "number") return "number";
if (Array.isArray(value)) return "stringList";
if (typeof value === "string") {
const k = key.toLowerCase();
if (/emoji/.test(k)) return "emoji";
if (/guild.?id|guild_id/.test(k)) return "discordId";
if (/category/.test(k)) return "discordCategory";
if (/channel|forum/.test(k)) return "discordChannel";
if (/role|staff|admin|moderator/.test(k)) return "discordRole";
if (value.length > 100) return "textarea";
return "text";
}
return "text";
}
export function collectConfigFields(
obj: Record<string, unknown>,
prefix = "",
): ConfigField[] {
const fields: ConfigField[] = [];
for (const [key, val] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${key}` : key;
if (Array.isArray(val)) {
const hasObjects =
val.length > 0 &&
typeof val[0] === "object" &&
val[0] !== null &&
!Array.isArray(val[0]);
if (hasObjects) continue;
fields.push({
path,
label: labelFromKey(key),
type: "stringList",
});
continue;
}
if (
val &&
typeof val === "object" &&
Object.keys(val as object).length > 0
) {
fields.push(...collectConfigFields(val as Record<string, unknown>, path));
continue;
}
fields.push({
path,
label: labelFromKey(key),
type: inferFieldType(key, val),
});
}
return fields;
}
export function schemaFromObject(data: Record<string, unknown>): ConfigSchema {
const fields = collectConfigFields(data);
if (fields.length === 0) {
return [
{
title: "Configuration",
fields: [],
},
];
}
return [
{
title: "Settings",
description: "Values from this addon’s config file.",
fields,
},
];
}
+77
View File
@@ -0,0 +1,77 @@
/** Curated Google Fonts (keep in sync with Structures/googleFonts.js). */
export const SYSTEM_FONT_ID = "system";
export const GOOGLE_FONTS = [
{ id: "Inter", label: "Inter" },
{ id: "Roboto", label: "Roboto" },
{ id: "Open Sans", label: "Open Sans" },
{ id: "Lato", label: "Lato" },
{ id: "Montserrat", label: "Montserrat" },
{ id: "Poppins", label: "Poppins" },
{ id: "Nunito", label: "Nunito" },
{ id: "Raleway", label: "Raleway" },
{ id: "Work Sans", label: "Work Sans" },
{ id: "Source Sans 3", label: "Source Sans 3" },
{ id: "DM Sans", label: "DM Sans" },
{ id: "Manrope", label: "Manrope" },
{ id: "Outfit", label: "Outfit" },
{ id: "Rubik", label: "Rubik" },
{ id: "Ubuntu", label: "Ubuntu" },
{ id: "Fira Sans", label: "Fira Sans" },
{ id: "PT Sans", label: "PT Sans" },
{ id: "Noto Sans", label: "Noto Sans" },
{ id: "Oswald", label: "Oswald" },
{ id: "Merriweather", label: "Merriweather" },
{ id: "Playfair Display", label: "Playfair Display" },
{ id: "Libre Baskerville", label: "Libre Baskerville" },
{ id: "JetBrains Mono", label: "JetBrains Mono" },
{ id: "Fira Code", label: "Fira Code" },
] as const;
export const GOOGLE_FONT_IDS = new Set<string>(
GOOGLE_FONTS.map((f) => f.id),
);
export function googleFontsCssUrl(familyIds: string[]) {
const families = familyIds
.map((id) => {
const encoded = encodeURIComponent(id).replace(/%20/g, "+");
return `family=${encoded}:wght@400;600;700;800`;
})
.join("&");
return `https://fonts.googleapis.com/css2?${families}&display=swap`;
}
/** Load all curated fonts in the dashboard (dropdown + preview labels). */
export function injectGoogleFontsStylesheet() {
const id = "supportbot-google-fonts";
if (document.getElementById(id)) return;
const link = document.createElement("link");
link.id = id;
link.rel = "stylesheet";
link.href = googleFontsCssUrl(GOOGLE_FONTS.map((f) => f.id));
document.head.appendChild(link);
}
export function normalizeGoogleFontValue(fontFamily: unknown): string {
if (!fontFamily || typeof fontFamily !== "string") return "Inter";
const trimmed = fontFamily.trim();
if (trimmed === SYSTEM_FONT_ID) return SYSTEM_FONT_ID;
if (!trimmed.includes(",")) {
return GOOGLE_FONT_IDS.has(trimmed) ? trimmed : "Inter";
}
const match = trimmed.match(/^["']?([^"',]+)["']?/);
const first = match?.[1]?.trim();
if (first && GOOGLE_FONT_IDS.has(first)) {
return first;
}
return "Inter";
}
export function fontFamilyCss(fontId: string): string {
if (fontId === SYSTEM_FONT_ID) {
return 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
}
return `"${fontId}", ui-sans-serif, system-ui, sans-serif`;
}
+39
View File
@@ -0,0 +1,39 @@
import type { ConfigField, ConfigSchema } from "@/lib/config-schemas";
import { collectConfigFields } from "@/lib/generic-config-schema";
function labelFromTopKey(key: string): string {
return key
.replace(/_/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Adds any leaf paths from `data` that are not already in `schema`. */
export function mergeSchemaWithData(
schema: ConfigSchema,
data: Record<string, unknown>,
): ConfigSchema {
const known = new Set(schema.flatMap((s) => s.fields.map((f) => f.path)));
const discovered = collectConfigFields(data).filter((f) => !known.has(f.path));
if (discovered.length === 0) return schema;
const byTop = new Map<string, ConfigField[]>();
for (const field of discovered) {
const top = field.path.split(".")[0] ?? "Other";
const list = byTop.get(top) ?? [];
list.push(field);
byTop.set(top, list);
}
const extraSections = [...byTop.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([top, fields]) => ({
title: labelFromTopKey(top),
description:
"These options exist in your config file but are not in the main editor sections.",
fields: fields.sort((a, b) => a.path.localeCompare(b.path)),
}));
return [...schema, ...extraSections];
}
+27
View File
@@ -0,0 +1,27 @@
import type { ModuleStatus } from "@/components/overview/module-strip-card";
export interface ModuleStatusInfo {
status: ModuleStatus;
subtitle: string;
passed?: number;
total?: number;
}
export const STATUS_BADGE: Record<ModuleStatus, string> = {
todo: "Needs setup",
progress: "In progress",
done: "Active",
};
export function getPluginModuleMeta(
pluginId: string,
modules?: Record<string, ModuleStatusInfo>,
fallbackSubtitle = "Configure module",
): ModuleStatusInfo {
const info = modules?.[pluginId];
if (info) return info;
return {
status: "todo",
subtitle: fallbackSubtitle,
};
}
+23
View File
@@ -0,0 +1,23 @@
/** Normalize user/config colour strings to #rrggbb (lowercase). */
export function normalizeHex(value: string, fallback = "#000000"): string {
let s = String(value ?? "").trim();
if (!s) return fallback;
if (!s.startsWith("#")) s = `#${s}`;
const hex = s.slice(1);
if (/^[0-9a-fA-F]{3}$/.test(hex)) {
return `#${hex
.split("")
.map((c) => c + c)
.join("")
.toLowerCase()}`;
}
if (/^[0-9a-fA-F]{6}$/.test(hex)) {
return `#${hex.toLowerCase()}`;
}
return fallback;
}
export function isValidHex(value: string): boolean {
const s = String(value ?? "").trim();
return /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(s) || /^[0-9a-fA-F]{6}$/.test(s);
}
+31
View File
@@ -0,0 +1,31 @@
import { toast } from "sonner";
import type { ApiResponse } from "@/api/client";
export async function notifyConfigSave<T>(
saveFn: () => Promise<ApiResponse<T>>,
): Promise<ApiResponse<T>> {
const saveToast = toast.loading("Saving changes…");
try {
const res = await saveFn();
toast.success("Changes saved", { id: saveToast });
if (res.botRestart) {
const restartToast = toast.loading("Restarting bot…");
if (res.botRestart.success) {
toast.success("Bot started", { id: restartToast });
} else {
toast.error(res.botRestart.error ?? "Could not start bot", {
id: restartToast,
});
}
}
return res;
} catch (err) {
toast.error(err instanceof Error ? err.message : "Save failed", {
id: saveToast,
});
throw err;
}
}
+204
View File
@@ -0,0 +1,204 @@
export const CONFIG_FILES = [
"supportbot",
"ticket-panel",
"commands",
"messages",
"supportbot-ai",
] as const;
export type ConfigFile = (typeof CONFIG_FILES)[number];
export type DashboardRole =
| "owner"
| "admin"
| "moderator"
| "editor"
| "viewer"
| "custom";
export interface ConfigFilePermission {
view: boolean;
edit: boolean;
}
export interface DashboardPermissions {
overview: boolean;
logs: boolean;
transcripts: boolean;
settings: { view: boolean; update: boolean };
configs: Record<ConfigFile, ConfigFilePermission>;
users: { view: boolean; manage: boolean };
}
export interface DashboardUser {
id: string;
username: string;
globalName: string | null;
avatar: string;
role: DashboardRole;
isOwner: boolean;
permissions: DashboardPermissions;
}
export interface StoredDashboardUser extends DashboardUser {
enabled: boolean;
yamlOwner: boolean;
createdAt: number;
updatedAt: number;
}
export const ROLE_LABELS: Record<DashboardRole, string> = {
owner: "Owner",
admin: "Admin",
moderator: "Moderator",
editor: "Editor",
viewer: "Viewer",
custom: "Custom",
};
export const ROLE_HINTS: Record<DashboardRole, string> = {
owner: "Full access to everything",
admin: "Full access including user management",
moderator: "View dashboard, logs, transcripts, and configs",
editor: "Edit configuration files",
viewer: "View-only access",
custom: "Permissions you configure below",
};
export const CONFIG_FILE_LABELS: Record<ConfigFile, string> = {
supportbot: "Bot config",
"ticket-panel": "Ticket system",
commands: "Commands",
messages: "Messages",
"supportbot-ai": "AI assistant",
};
export function hasPermission(
permissions: DashboardPermissions | null | undefined,
key: string,
): boolean {
if (!permissions) return false;
if (key === "overview") return permissions.overview;
if (key === "logs") return permissions.logs;
if (key === "transcripts") return permissions.transcripts;
if (key === "settings.view") return permissions.settings.view;
if (key === "settings.update") return permissions.settings.update;
if (key === "users.view") return permissions.users.view;
if (key === "users.manage") return permissions.users.manage;
const viewMatch = key.match(/^configs\.([a-z0-9-]+)\.view$/);
if (viewMatch) {
const file = viewMatch[1] as ConfigFile;
return Boolean(permissions.configs[file]?.view);
}
const editMatch = key.match(/^configs\.([a-z0-9-]+)\.edit$/);
if (editMatch) {
const file = editMatch[1] as ConfigFile;
return Boolean(permissions.configs[file]?.edit);
}
return false;
}
export function canViewConfig(
permissions: DashboardPermissions | null | undefined,
file: string,
): boolean {
return hasPermission(permissions, `configs.${file}.view`);
}
export function canEditConfig(
permissions: DashboardPermissions | null | undefined,
file: string,
): boolean {
return hasPermission(permissions, `configs.${file}.edit`);
}
export function canViewAnyConfig(
permissions: DashboardPermissions | null | undefined,
): boolean {
return CONFIG_FILES.some((file) => canViewConfig(permissions, file));
}
function configMap(view: boolean, edit: boolean): Record<ConfigFile, ConfigFilePermission> {
return CONFIG_FILES.reduce(
(acc, file) => {
acc[file] = { view, edit };
return acc;
},
{} as Record<ConfigFile, ConfigFilePermission>,
);
}
export function fullPermissions(): DashboardPermissions {
return {
overview: true,
logs: true,
transcripts: true,
settings: { view: true, update: true },
configs: configMap(true, true),
users: { view: true, manage: true },
};
}
export function defaultPermissionsForRole(
role: DashboardRole,
custom?: DashboardPermissions,
): DashboardPermissions {
switch (role) {
case "owner":
case "admin":
return fullPermissions();
case "moderator":
return {
overview: true,
logs: true,
transcripts: true,
settings: { view: true, update: false },
configs: configMap(true, false),
users: { view: false, manage: false },
};
case "editor":
return {
overview: true,
logs: true,
transcripts: false,
settings: { view: true, update: false },
configs: configMap(true, true),
users: { view: false, manage: false },
};
case "viewer":
return {
overview: true,
logs: true,
transcripts: true,
settings: { view: true, update: false },
configs: configMap(true, false),
users: { view: false, manage: false },
};
case "custom":
return custom ?? defaultPermissionsForRole("viewer");
default:
return defaultPermissionsForRole("viewer");
}
}
export function displayDiscordName(
user: Pick<DashboardUser, "username" | "globalName">,
): string {
return user.globalName || user.username;
}
export function firstAllowedPath(
permissions: DashboardPermissions | null | undefined,
): string {
if (hasPermission(permissions, "overview")) return "/";
if (hasPermission(permissions, "logs")) return "/logs";
if (canViewAnyConfig(permissions)) return "/configs?file=supportbot";
if (hasPermission(permissions, "transcripts")) return "/transcripts";
if (hasPermission(permissions, "settings.view")) return "/settings";
if (hasPermission(permissions, "users.view")) return "/users";
return "/login";
}
+88
View File
@@ -0,0 +1,88 @@
import type { IconSvgElement } from "@hugeicons/react";
import {
AiBrain01Icon,
CommandIcon,
File02Icon,
Message01Icon,
Settings02Icon,
Ticket01Icon,
} from "@hugeicons/core-free-icons";
export type PluginCategory =
| "all"
| "tickets"
| "community"
| "server";
export interface PluginModule {
id: string;
title: string;
description: string;
icon: IconSvgElement;
href: string;
category: PluginCategory;
}
export const plugins: PluginModule[] = [
{
id: "tickets",
title: "Ticket system",
description:
"Create and manage support tickets with departments, priorities, and AI assistance.",
icon: Ticket01Icon,
href: "/configs?file=ticket-panel",
category: "tickets",
},
{
id: "supportbot",
title: "Core bot settings",
description:
"General bot options, activity status, embed colours, roles, and welcome messages.",
icon: Settings02Icon,
href: "/configs?file=supportbot",
category: "server",
},
{
id: "transcripts",
title: "Ticket transcripts",
description:
"Browse and preview HTML transcripts saved when tickets are closed.",
icon: File02Icon,
href: "/transcripts",
category: "tickets",
},
{
id: "suggestions",
title: "Suggestions",
description:
"Configure suggestion channels, voting emojis, and forum thread behaviour.",
icon: Message01Icon,
href: "/configs?file=messages",
category: "community",
},
{
id: "ai",
title: "AI assistant",
description:
"SupportBot AI mode settings for in-ticket conversational help.",
icon: AiBrain01Icon,
href: "/configs?file=supportbot-ai",
category: "community",
},
{
id: "commands",
title: "Commands",
description:
"Enable or disable slash commands and customise command permissions.",
icon: CommandIcon,
href: "/configs?file=commands",
category: "server",
},
];
export const pluginTabs: { id: PluginCategory | "all"; label: string }[] = [
{ id: "all", label: "All modules" },
{ id: "tickets", label: "Tickets" },
{ id: "community", label: "Community" },
{ id: "server", label: "Server" },
];
+254
View File
@@ -0,0 +1,254 @@
import type { IconSvgElement } from "@hugeicons/react";
import {
AiBrain01Icon,
CommandIcon,
ComputerTerminal01Icon,
DashboardSquare02Icon,
File02Icon,
Message01Icon,
Settings02Icon,
Ticket01Icon,
} from "@hugeicons/core-free-icons";
import {
configSchemas,
type ConfigFileName,
type ConfigSchema,
} from "@/lib/config-schemas";
import { plugins } from "@/lib/plugins";
export type SearchCategory = "page" | "module" | "setting" | "command";
export interface SearchItem {
id: string;
title: string;
subtitle?: string;
href: string;
category: SearchCategory;
icon?: IconSvgElement;
keywords: string[];
}
const FILE_LABELS: Record<ConfigFileName, string> = {
supportbot: "Bot config",
"ticket-panel": "Ticket panel",
commands: "Commands",
messages: "Messages",
"supportbot-ai": "AI assistant",
};
const COMMAND_ENTRIES: {
key: string;
label: string;
description: string;
}[] = [
{ key: "Help", label: "Help", description: "List all slash commands" },
{ key: "Info", label: "Info", description: "Server information" },
{ key: "TicketPanel", label: "Ticket panel", description: "Create ticket panel" },
{ key: "OpenTicket", label: "Open ticket", description: "Open a support ticket" },
{ key: "CloseTicket", label: "Close ticket", description: "Close a ticket" },
{ key: "TicketManage", label: "Manage ticket", description: "Manage an existing ticket" },
{ key: "Suggestion", label: "Suggestion", description: "Submit a suggestion" },
{ key: "Embed", label: "Embed", description: "Send an embed message" },
{ key: "Translate", label: "Translate", description: "Translate text" },
{ key: "Ping", label: "Ping", description: "Check bot latency" },
{ key: "AI", label: "AI", description: "AI assistant command" },
{ key: "Settings", label: "Settings command", description: "Bot settings via Discord" },
{ key: "Mod", label: "Moderation", description: "Moderation tools" },
];
function walkSchema(
file: ConfigFileName,
schema: ConfigSchema,
items: SearchItem[],
) {
const fileLabel = FILE_LABELS[file];
for (const section of schema) {
for (const field of section.fields) {
items.push({
id: `setting:${file}:${field.path}`,
title: field.label,
subtitle: `${fileLabel} · ${section.title}`,
href: `/configs?file=${file}`,
category: "setting",
icon: iconForFile(file),
keywords: [
field.path,
field.label,
section.title,
fileLabel,
file,
field.description ?? "",
field.type,
].map((s) => s.toLowerCase()),
});
}
}
}
function iconForFile(file: ConfigFileName): IconSvgElement {
switch (file) {
case "supportbot":
return Settings02Icon;
case "ticket-panel":
return Ticket01Icon;
case "messages":
return Message01Icon;
case "supportbot-ai":
return AiBrain01Icon;
case "commands":
return CommandIcon;
default:
return Settings02Icon;
}
}
function buildSearchIndex(): SearchItem[] {
const items: SearchItem[] = [
{
id: "page:dashboard",
title: "Dashboard",
subtitle: "Overview and module status",
href: "/",
category: "page",
icon: DashboardSquare02Icon,
keywords: ["home", "overview", "stats"],
},
{
id: "page:logs",
title: "Logs",
subtitle: "Live bot console output",
href: "/logs",
category: "page",
icon: ComputerTerminal01Icon,
keywords: ["console", "output", "errors", "warn"],
},
{
id: "page:settings",
title: "Settings",
subtitle: "Updates and maintenance",
href: "/settings",
category: "page",
icon: Settings02Icon,
keywords: ["update", "version", "software"],
},
{
id: "page:transcripts",
title: "Transcripts",
subtitle: "Closed ticket transcripts",
href: "/transcripts",
category: "page",
icon: File02Icon,
keywords: ["tickets", "archive", "html"],
},
];
for (const plugin of plugins) {
items.push({
id: `module:${plugin.id}`,
title: plugin.title,
subtitle: plugin.description,
href: plugin.href,
category: "module",
icon: plugin.icon,
keywords: [
plugin.id,
plugin.title,
plugin.description,
plugin.category,
].map((s) => s.toLowerCase()),
});
}
for (const [file, schema] of Object.entries(configSchemas)) {
if (schema === "commands") continue;
walkSchema(file as ConfigFileName, schema, items);
}
for (const cmd of COMMAND_ENTRIES) {
items.push({
id: `command:${cmd.key}`,
title: cmd.label,
subtitle: `Command · ${cmd.description}`,
href: "/configs?file=commands",
category: "command",
icon: CommandIcon,
keywords: [
cmd.key,
cmd.label,
cmd.description,
"slash",
"commands",
].map((s) => s.toLowerCase()),
});
}
items.push({
id: "setting:commands:all",
title: "Slash commands",
subtitle: "Commands · Enable and configure all commands",
href: "/configs?file=commands",
category: "setting",
icon: CommandIcon,
keywords: ["commands", "slash", "enable", "permissions"],
});
return items;
}
export const SEARCH_INDEX = buildSearchIndex();
const CATEGORY_LABEL: Record<SearchCategory, string> = {
page: "Pages",
module: "Modules",
setting: "Settings",
command: "Commands",
};
export function searchDashboard(query: string, limit = 12): SearchItem[] {
const q = query.trim().toLowerCase();
if (!q) return [];
const scored = SEARCH_INDEX.map((item) => {
let score = 0;
const title = item.title.toLowerCase();
const subtitle = (item.subtitle ?? "").toLowerCase();
if (title === q) score += 100;
else if (title.startsWith(q)) score += 40;
else if (title.includes(q)) score += 20;
if (subtitle.includes(q)) score += 10;
for (const kw of item.keywords) {
if (kw === q) score += 15;
else if (kw.startsWith(q)) score += 8;
else if (kw.includes(q)) score += 4;
}
return { item, score };
})
.filter((r) => r.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit);
return scored.map((r) => r.item);
}
export function groupSearchResults(items: SearchItem[]) {
const groups: { category: SearchCategory; label: string; items: SearchItem[] }[] =
[];
const order: SearchCategory[] = ["page", "module", "setting", "command"];
for (const cat of order) {
const inCat = items.filter((i) => i.category === cat);
if (inCat.length) {
groups.push({
category: cat,
label: CATEGORY_LABEL[cat],
items: inCat,
});
}
}
return groups;
}
+38
View File
@@ -0,0 +1,38 @@
export interface SetupStep {
id: string;
title: string;
description: string;
complete: boolean;
}
export interface SetupStatus {
complete: boolean;
steps: SetupStep[];
suggestedRedirectUri: string;
oauthCallbackPath: string;
}
export interface FieldValidationResult {
ok: boolean;
error?: string;
warning?: string;
bot?: { id: string; username: string };
applicationId?: string;
users?: { id: string; username: string; globalName: string | null }[];
redirectUri?: string;
ownerUserIds?: string[];
}
export interface SetupValidateResponse {
ok: boolean;
results: Record<string, FieldValidationResult>;
}
export interface SetupFormValues {
botToken: string;
secretKey: string;
clientId: string;
clientSecret: string;
redirectUri: string;
ownerUserIds: string;
}
@@ -0,0 +1,99 @@
import type { ConfigSchema } from "@/lib/config-schemas";
export const transcriptTemplateSchema: ConfigSchema = [
{
title: "Colors",
description: "Theme colors used across the transcript page.",
fields: [
{ path: "Theme.Background", label: "Page background", type: "color" },
{ path: "Theme.Panel", label: "Panel background", type: "color" },
{ path: "Theme.PanelSecondary", label: "Secondary panel", type: "color" },
{ path: "Theme.Border", label: "Borders", type: "color" },
{ path: "Theme.Text", label: "Main text", type: "color" },
{ path: "Theme.Muted", label: "Muted text", type: "color" },
{ path: "Theme.Accent", label: "Accent", type: "color" },
{ path: "Theme.Link", label: "Links", type: "color" },
{ path: "Theme.AttachmentLink", label: "Attachment links", type: "color" },
{ path: "Theme.CodeBackground", label: "Code blocks", type: "color" },
],
},
{
title: "Typography",
fields: [
{
path: "Typography.FontFamily",
label: "Font family",
type: "googleFont",
description: "Google Fonts are loaded automatically in saved transcripts.",
},
],
},
{
title: "Header",
fields: [
{ path: "Header.Title", label: "Title", type: "text" },
{
path: "Header.Subtitle",
label: "Subtitle",
type: "textarea",
description:
"Placeholders: {{ticketName}} {{ticketId}} {{messageCount}} {{reason}}. Use **text** for bold.",
},
{ path: "Header.Badge", label: "Badge label", type: "text" },
{
path: "Header.ShowMetaCards",
label: "Show info cards",
type: "boolean",
},
],
},
{
title: "Info card labels",
fields: [
{ path: "Meta.ChannelLabel", label: "Channel label", type: "text" },
{ path: "Meta.TicketIdLabel", label: "Ticket ID label", type: "text" },
{
path: "Meta.MessageCountLabel",
label: "Message count label",
type: "text",
},
{
path: "Meta.CloseReasonLabel",
label: "Close reason label",
type: "text",
},
{
path: "Meta.DefaultCloseReason",
label: "Default close reason",
type: "text",
},
],
},
{
title: "Messages section",
fields: [
{
path: "Messages.SectionTitle",
label: "Section heading",
type: "text",
},
{
path: "Messages.EmptyState",
label: "Empty state text",
type: "text",
},
],
},
{
title: "Advanced CSS",
description: "Optional extra CSS appended to the transcript stylesheet.",
fields: [
{
path: "Advanced.CustomCss",
label: "Custom CSS",
type: "textarea",
description: "Example: .badge { font-size: 1rem; }",
},
],
},
];
@@ -0,0 +1,41 @@
export interface TranscriptTemplate {
Theme: {
Background: string;
Panel: string;
PanelSecondary: string;
PanelTertiary: string;
Border: string;
Text: string;
Muted: string;
Accent: string;
AccentSoft: string;
AccentBorder: string;
AccentText: string;
Link: string;
AttachmentLink: string;
CodeBackground: string;
};
Typography: {
FontFamily: string;
};
Header: {
Title: string;
Subtitle: string;
Badge: string;
ShowMetaCards: boolean;
};
Meta: {
ChannelLabel: string;
TicketIdLabel: string;
MessageCountLabel: string;
CloseReasonLabel: string;
DefaultCloseReason: string;
};
Messages: {
SectionTitle: string;
EmptyState: string;
};
Advanced: {
CustomCss: string;
};
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more