Dashboard Release
This commit is contained in:
1 parent
4fb26f3996
commit
033fb87269
8 files changed
+308
-90
No files matched your search
@@ -14,6 +14,7 @@ import Settings from "./pages/Settings";
|
||||
import SystemDetails from "./pages/SystemDetails";
|
||||
import Logs from "./pages/Logs";
|
||||
import Transcripts from "./pages/Transcripts";
|
||||
import Tickets from "./pages/Tickets";
|
||||
import Users from "./pages/Users";
|
||||
|
||||
function Protected({ children }: { children: React.ReactNode }) {
|
||||
@@ -90,6 +91,14 @@ export default function App() {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="tickets"
|
||||
element={
|
||||
<RequirePermission permission="transcripts">
|
||||
<Tickets />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="transcripts"
|
||||
element={
|
||||
|
||||
@@ -327,6 +327,22 @@ export const api = {
|
||||
>
|
||||
>("/api/system/transcripts"),
|
||||
|
||||
listOpenTickets: () =>
|
||||
request<
|
||||
ApiResponse<{
|
||||
guildId: string;
|
||||
tickets: {
|
||||
ticket_id: string;
|
||||
user_id: string;
|
||||
subject: string;
|
||||
description: string;
|
||||
department: string;
|
||||
priority: string;
|
||||
created_at: number;
|
||||
}[];
|
||||
}>
|
||||
>("/api/system/tickets/open"),
|
||||
|
||||
getTranscript: (id: string) =>
|
||||
request<ApiResponse<string>>(`/api/system/transcripts/${id}`),
|
||||
|
||||
|
||||
@@ -65,6 +65,14 @@ const sections: {
|
||||
{ title: "Console", url: "/logs", icon: ComputerTerminal01Icon, permission: "logs" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Access & Logs",
|
||||
items: [
|
||||
{ title: "Users", url: "/users", icon: UserMultiple02Icon, permission: "users.view" },
|
||||
{ title: "Tickets", url: "/tickets", icon: Ticket01Icon, permission: "transcripts" },
|
||||
{ title: "Transcripts", url: "/transcripts", icon: File02Icon, permission: "transcripts" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Configuration",
|
||||
items: [
|
||||
@@ -73,14 +81,7 @@ const sections: {
|
||||
{ title: "AI Assistant", url: "/configs?file=supportbot-ai", icon: AiBrain01Icon },
|
||||
{ title: "Messages", url: "/configs?file=messages", icon: Message01Icon },
|
||||
{ title: "Commands", url: "/configs?file=commands", icon: CommandIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Access & Logs",
|
||||
items: [
|
||||
{ title: "Users", url: "/users", icon: UserMultiple02Icon, permission: "users.view" },
|
||||
{ title: "Staff Roles", url: "/configs?file=supportbot", icon: UserMultiple02Icon },
|
||||
{ title: "Transcripts", url: "/transcripts", icon: File02Icon, permission: "transcripts" },
|
||||
{ title: "Staff Roles", url: "/configs?file=supportbot&tab=Roles", icon: UserMultiple02Icon },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { AlertCircleIcon, FloppyDiskIcon } from "@hugeicons/core-free-icons";
|
||||
import { api, maskConfigSecrets } from "@/api/client";
|
||||
import { Icon } from "@/components/icon";
|
||||
@@ -92,7 +93,10 @@ export function ConfigVisualEditor({ configFile }: ConfigVisualEditorProps) {
|
||||
const [mode, setMode] = useState<EditorMode>("visual");
|
||||
const [rawYaml, setRawYaml] = useState("");
|
||||
const [rawOriginal, setRawOriginal] = useState("");
|
||||
const [activeSectionTitle, setActiveSectionTitle] = useState<string | null>(null);
|
||||
const [searchParams] = useSearchParams();
|
||||
const [activeSectionTitle, setActiveSectionTitle] = useState<string | null>(
|
||||
() => searchParams.get("tab") || null
|
||||
);
|
||||
|
||||
const baseSchema = getSchema(configFile);
|
||||
|
||||
@@ -103,11 +107,17 @@ export function ConfigVisualEditor({ configFile }: ConfigVisualEditorProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (effectiveSchema && effectiveSchema !== "commands") {
|
||||
if (!activeSectionTitle || !effectiveSchema.find(s => s.title === activeSectionTitle)) {
|
||||
const tabParam = searchParams.get("tab");
|
||||
if (tabParam && effectiveSchema.find(s => s.title.toLowerCase() === tabParam.toLowerCase())) {
|
||||
const found = effectiveSchema.find(s => s.title.toLowerCase() === tabParam.toLowerCase());
|
||||
if (found && found.title !== activeSectionTitle) {
|
||||
setActiveSectionTitle(found.title);
|
||||
}
|
||||
} else if (!activeSectionTitle || !effectiveSchema.find(s => s.title === activeSectionTitle)) {
|
||||
setActiveSectionTitle(effectiveSchema[0]?.title || null);
|
||||
}
|
||||
}
|
||||
}, [effectiveSchema, activeSectionTitle]);
|
||||
}, [effectiveSchema, activeSectionTitle, searchParams]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
||||
@@ -20,6 +20,46 @@ interface DashboardBrandingSettingsProps {
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export function DashboardBrandingDangerZone({ canEdit }: DashboardBrandingSettingsProps) {
|
||||
const { setBrandingLocal } = useBranding();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (!canEdit) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Reset all branding text and favicon to their original defaults.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={saving}
|
||||
onClick={() => void resetAll()}
|
||||
>
|
||||
Reset Branding to Default
|
||||
</Button>
|
||||
{message && <p className="text-xs text-muted-foreground">{message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardBrandingSettings({ canEdit }: DashboardBrandingSettingsProps) {
|
||||
const { branding, setBrandingLocal } = useBranding();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
@@ -86,18 +126,10 @@ export function DashboardBrandingSettings({ canEdit }: DashboardBrandingSettings
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,41 +254,7 @@ export function DashboardBrandingSettings({ canEdit }: DashboardBrandingSettings
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Danger Zone */}
|
||||
{canEdit ? (
|
||||
<details className="group space-y-4 border-t border-border pt-6 [&_summary::-webkit-details-marker]:hidden">
|
||||
<summary className="flex cursor-pointer items-center justify-between font-medium text-sm text-destructive hover:opacity-80 transition-opacity">
|
||||
Danger Zone
|
||||
<svg
|
||||
className="h-4 w-4 transition-transform group-open:rotate-180"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</summary>
|
||||
<div className="pt-2 space-y-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Reset all branding text and favicon to their original defaults.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={saving || uploading}
|
||||
onClick={() => void resetAll()}
|
||||
>
|
||||
Reset Branding to Default
|
||||
</Button>
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -192,9 +192,9 @@ export default function Overview() {
|
||||
title: "Open ticket queue",
|
||||
subtitle: `${openCount} waiting`,
|
||||
icon: Ticket01Icon,
|
||||
href: "/transcripts",
|
||||
status: "todo" as ModuleStatus,
|
||||
meta: "View transcripts",
|
||||
href: "/tickets",
|
||||
status: "done" as ModuleStatus,
|
||||
meta: "View live tickets",
|
||||
},
|
||||
]
|
||||
: tab === "closed"
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
saveAccentTheme,
|
||||
} from "@/lib/accent-theme";
|
||||
import { normalizeHex } from "@/lib/normalize-hex";
|
||||
import { DashboardBrandingSettings } from "@/components/settings/dashboard-branding-settings";
|
||||
import { DashboardBrandingSettings, DashboardBrandingDangerZone } from "@/components/settings/dashboard-branding-settings";
|
||||
import { DashboardApiSettings } from "@/components/settings/dashboard-api-settings";
|
||||
|
||||
export default function Settings() {
|
||||
@@ -42,6 +42,7 @@ export default function Settings() {
|
||||
() => getStoredAccentHex() ?? DEFAULT_ACCENT_HEX,
|
||||
);
|
||||
const [accentSaved, setAccentSaved] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<"appearance" | "api" | "updates">("appearance");
|
||||
|
||||
const loadCheck = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -88,7 +89,37 @@ export default function Settings() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-border bg-card">
|
||||
<div className="flex flex-col md:flex-row gap-8 items-start">
|
||||
<aside className="w-full md:w-56 shrink-0 sticky top-6">
|
||||
<nav className="flex flex-row md:flex-col overflow-x-auto space-x-2 md:space-x-0 md:space-y-1 pb-2 md:pb-0">
|
||||
<Button
|
||||
variant={activeTab === "appearance" ? "secondary" : "ghost"}
|
||||
className="justify-start w-full"
|
||||
onClick={() => setActiveTab("appearance")}
|
||||
>
|
||||
Appearance
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === "api" ? "secondary" : "ghost"}
|
||||
className="justify-start w-full"
|
||||
onClick={() => setActiveTab("api")}
|
||||
>
|
||||
API Config
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === "updates" ? "secondary" : "ghost"}
|
||||
className="justify-start w-full"
|
||||
onClick={() => setActiveTab("updates")}
|
||||
>
|
||||
Update Checker
|
||||
</Button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 w-full max-w-4xl space-y-6">
|
||||
{activeTab === "appearance" && (
|
||||
<div className="space-y-6">
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Dashboard appearance</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -100,36 +131,33 @@ export default function Settings() {
|
||||
<DashboardBrandingSettings canEdit={canUpdate} />
|
||||
|
||||
<div className="border-t border-border pt-4">
|
||||
<p className="mb-3 text-sm font-medium">Accent colour</p>
|
||||
<p className="mb-1 text-sm font-medium">Accent colour</p>
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
Buttons, links, and highlights — saved in this browser only.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accent-colour">Accent colour</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<ColorPicker
|
||||
value={accentHex}
|
||||
onChange={(hex) => {
|
||||
setAccentHex(hex);
|
||||
saveAccentTheme(hex);
|
||||
setAccentSaved("Accent updated.");
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
id="accent-colour"
|
||||
value={accentHex}
|
||||
onChange={(e) => setAccentHex(e.target.value)}
|
||||
onBlur={() => {
|
||||
const next = normalizeHex(accentHex, DEFAULT_ACCENT_HEX);
|
||||
setAccentHex(next);
|
||||
saveAccentTheme(next);
|
||||
setAccentSaved("Accent updated.");
|
||||
}}
|
||||
className="w-32 border-border bg-secondary/30 font-mono uppercase"
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ColorPicker
|
||||
value={accentHex}
|
||||
onChange={(hex) => {
|
||||
setAccentHex(hex);
|
||||
saveAccentTheme(hex);
|
||||
setAccentSaved("Accent updated.");
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
id="accent-colour"
|
||||
value={accentHex}
|
||||
onChange={(e) => setAccentHex(e.target.value)}
|
||||
onBlur={() => {
|
||||
const next = normalizeHex(accentHex, DEFAULT_ACCENT_HEX);
|
||||
setAccentHex(next);
|
||||
saveAccentTheme(next);
|
||||
setAccentSaved("Accent updated.");
|
||||
}}
|
||||
className="w-32 border-border bg-secondary/30 font-mono uppercase"
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -143,7 +171,7 @@ export default function Settings() {
|
||||
Reset to default
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-sm">
|
||||
<div className="flex flex-wrap gap-2 text-sm mt-5">
|
||||
<span
|
||||
className="rounded-md bg-primary px-3 py-1.5 font-medium text-primary-foreground"
|
||||
>
|
||||
@@ -163,6 +191,20 @@ export default function Settings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{canUpdate && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-destructive">Danger Zone</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DashboardBrandingDangerZone canEdit={canUpdate} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "api" && (
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">API Configuration</CardTitle>
|
||||
@@ -174,7 +216,9 @@ export default function Settings() {
|
||||
<DashboardApiSettings />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === "updates" && (
|
||||
<Card className="border-border bg-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Software updates</CardTitle>
|
||||
@@ -273,6 +317,9 @@ export default function Settings() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
RefreshIcon,
|
||||
Link01Icon,
|
||||
Clock01Icon,
|
||||
} 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 } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { formatUptime } from "@/api/client";
|
||||
|
||||
interface TicketItem {
|
||||
ticket_id: string;
|
||||
user_id: string;
|
||||
subject: string;
|
||||
description: string;
|
||||
department: string;
|
||||
priority: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export default function Tickets() {
|
||||
const [list, setList] = useState<TicketItem[]>([]);
|
||||
const [guildId, setGuildId] = useState<string>("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await api.listOpenTickets();
|
||||
if (res.data) {
|
||||
setGuildId(res.data.guildId);
|
||||
setList(res.data.tickets.sort((a, b) => b.created_at - a.created_at));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load open tickets");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadList();
|
||||
}, [loadList]);
|
||||
|
||||
const getTimeElapsed = (createdAt: number) => {
|
||||
const seconds = Math.floor((Date.now() - createdAt) / 1000);
|
||||
return formatUptime(seconds);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Open Tickets</h1>
|
||||
<p className="text-muted-foreground">
|
||||
View live tickets currently active in Discord.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => void loadList()} disabled={loading} variant="outline" size="sm">
|
||||
<Icon icon={RefreshIcon} size={16} className={loading ? "animate-spin mr-2" : "mr-2"} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="border-border bg-card">
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="space-y-4 p-6">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
) : list.length === 0 ? (
|
||||
<div className="p-12 text-center text-muted-foreground">
|
||||
No open tickets at the moment!
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{list.map((ticket) => (
|
||||
<div
|
||||
key={ticket.ticket_id}
|
||||
className="flex flex-col sm:flex-row items-start sm:items-center justify-between p-4 hover:bg-secondary/10 transition-colors gap-4"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-primary">
|
||||
{ticket.subject || "Untitled Ticket"}
|
||||
<span className="ml-2 text-xs font-mono text-muted-foreground">
|
||||
#{ticket.ticket_id}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
User ID: {ticket.user_id} • Dept: {ticket.department} • Priority: {ticket.priority}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground flex items-center whitespace-nowrap">
|
||||
<Icon icon={Clock01Icon} size={14} className="mr-1.5" />
|
||||
{getTimeElapsed(ticket.created_at)}
|
||||
</div>
|
||||
{guildId && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={`https://discord.com/channels/${guildId}/${ticket.ticket_id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Icon icon={Link01Icon} size={16} className="mr-2" />
|
||||
View
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user