Dashboard 1.0

Added Addon Builder
Added Emerald SRV API for One-Click Addon install
Added API Config
Update Checker for Bot and Panel
This commit is contained in:
1tzEmerald committed 2026-07-26 18:38:25 +01:00
1 parent 24ccc179a6
commit 1a6b360701
13 files changed
+720 -213

No files matched your search

+11 -1
View File
@@ -7,12 +7,14 @@ import Login from "./pages/Login";
import Setup from "./pages/Setup";
import { HomeRedirect } from "./components/home-redirect";
import { RequirePermission } from "./components/require-permission";
import Addons from "./pages/Addons";
import { AddonBuilder } from "@/pages/AddonBuilder";
import { DashboardSettings } from "@/pages/DashboardSettings";
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();
@@ -112,6 +114,14 @@ export default function App() {
</RequirePermission>
}
/>
<Route
path="addons/builder"
element={
<RequirePermission permission="settings.update">
<AddonBuilder />
</RequirePermission>
}
/>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+16 -4
View File
@@ -85,15 +85,21 @@ export interface ApiResponse<T> {
export interface CatalogAddon {
id: string;
name: string;
slug?: string;
description: string;
repositoryUrl: string;
price?: number;
is_external?: number;
external_store?: string;
jsFiles: string[];
configFiles: string[];
fileCount: number;
fileCount?: number;
installed?: boolean;
}
export interface UpdateCheckResult {
id: string;
name: string;
current: string;
latest: string;
updateAvailable: boolean;
@@ -269,12 +275,12 @@ export const api = {
}),
checkForUpdates: () =>
request<ApiResponse<UpdateCheckResult>>("/api/system/update-check"),
request<ApiResponse<UpdateCheckResult[]>>("/api/system/update-check"),
runUpdate: (version: string) =>
runUpdate: (repoId: string, version: string, url: string) =>
request<ApiResponse<unknown>>("/api/system/update", {
method: "POST",
body: JSON.stringify({ version }),
body: JSON.stringify({ repoId, version, url }),
}),
reloadConfigs: (file?: string) =>
@@ -547,6 +553,12 @@ export const api = {
body: JSON.stringify({ filename, data }),
}),
buildAddon: (payload: { name: string; description: string; permission: string; embed: any }) =>
request<ApiResponse<{ success: boolean }>>("/api/addons/build", {
method: "POST",
body: JSON.stringify(payload),
}),
getSetupStatus: () =>
request<ApiResponse<import("@/lib/setup-types").SetupStatus>>("/api/setup/status"),
+18 -16
View File
@@ -52,42 +52,42 @@ const mainNav: {
icon: IconSvgElement;
end?: boolean;
permission?: string;
}[] = [
{ title: "Dashboard", url: "/", icon: DashboardSquare02Icon, end: true, permission: "overview" },
];
}[] = [];
const sections: {
label: string;
items: { title: string; url: string; icon: IconSvgElement; permission?: string }[];
items: { title: string; url: string; icon: IconSvgElement; permission?: string; end?: boolean }[];
}[] = [
{
label: "Overview",
items: [
{ title: "Dashboard", url: "/", icon: DashboardSquare02Icon, end: true, permission: "overview" },
{ title: "Console", url: "/logs", icon: ComputerTerminal01Icon, permission: "logs" },
],
},
{
label: "Configuration",
items: [
{ title: "Bot Settings", url: "/configs?file=supportbot", icon: BotIcon },
{ title: "Commands", url: "/configs?file=commands", icon: CommandIcon },
{ title: "Messages", url: "/configs?file=messages", icon: Message01Icon },
],
},
{
label: "Features",
items: [
{ title: "Ticket System", url: "/configs?file=ticket-panel", icon: Ticket01Icon },
{ title: "Ticket Panel", url: "/configs?file=ticket-panel", icon: Ticket01Icon },
{ title: "AI Assistant", url: "/configs?file=supportbot-ai", icon: AiBrain01Icon },
{ title: "Transcripts", url: "/transcripts", icon: File02Icon, permission: "transcripts" },
{ title: "Messages", url: "/configs?file=messages", icon: Message01Icon },
{ title: "Commands", url: "/configs?file=commands", icon: CommandIcon },
],
},
{
label: "Management",
label: "Access & Logs",
items: [
{ title: "Users", url: "/users", icon: UserMultiple02Icon, permission: "users.view" },
{ title: "Staff Roles", url: "/configs?file=supportbot", icon: UserMultiple02Icon },
{ title: "Console Logs", url: "/logs", icon: ComputerTerminal01Icon, permission: "logs" },
{ title: "Transcripts", url: "/transcripts", icon: File02Icon, permission: "transcripts" },
],
},
{
label: "System",
items: [
{ title: "Addon Manager", url: "/addons", icon: PackageIcon, permission: "settings.view" },
{ title: "Addons", url: "/addons", icon: PackageIcon, permission: "settings.view" },
{ title: "Addon Builder", url: "/addons/builder", icon: CommandIcon, permission: "settings.update" },
{ title: "Dashboard Settings", url: "/settings", icon: Settings02Icon, permission: "settings.view" },
],
},
@@ -113,6 +113,7 @@ export function AppSidebar() {
title: addonConfigLabel(filename),
url: `/configs?file=${addonConfigFileParam(filename)}`,
icon: PackageIcon,
end: false,
})),
}
: null;
@@ -227,6 +228,7 @@ export function AppSidebar() {
<SidebarMenuButton asChild tooltip={item.title}>
<NavLink
to={item.url}
end={item.end}
className={({ isActive }) => cn(
(isActive || isConfigActive) && "bg-sidebar-accent font-medium",
)}
@@ -17,6 +17,7 @@ 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 { cn } from "@/lib/utils";
import { Textarea } from "@/components/ui/textarea";
import Editor from "@monaco-editor/react";
import { ConfigFieldInput } from "./config-field";
@@ -91,6 +92,7 @@ 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 baseSchema = getSchema(configFile);
@@ -99,6 +101,14 @@ export function ConfigVisualEditor({ configFile }: ConfigVisualEditorProps) {
return mergeSchemaWithData(baseSchema, data);
}, [baseSchema, data]);
useEffect(() => {
if (effectiveSchema && effectiveSchema !== "commands") {
if (!activeSectionTitle || !effectiveSchema.find(s => s.title === activeSectionTitle)) {
setActiveSectionTitle(effectiveSchema[0]?.title || null);
}
}
}, [effectiveSchema, activeSectionTitle]);
const load = useCallback(async () => {
setLoading(true);
setError("");
@@ -321,35 +331,57 @@ export function ConfigVisualEditor({ configFile }: ConfigVisualEditorProps) {
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>
<div className="grid gap-6 lg:grid-cols-[200px_1fr]">
<nav className="flex flex-col gap-1">
{effectiveSchema.map((section) => (
<button
key={section.title}
onClick={() => setActiveSectionTitle(section.title)}
className={cn(
"text-left px-3 py-2 text-sm rounded-md transition-colors",
activeSectionTitle === section.title
? "bg-primary text-primary-foreground font-medium"
: "text-muted-foreground hover:bg-secondary hover:text-foreground"
)}
>
{section.title}
</button>
))}
</nav>
<div className="min-w-0">
{effectiveSchema
.filter((section) => section.title === activeSectionTitle)
.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>
))}
</CardContent>
</Card>
))
</div>
</div>
) : null}
</div>
);
@@ -75,6 +75,18 @@ export function DashboardSearch({ className }: { className?: string }) {
return () => document.removeEventListener("mousedown", onPointerDown);
}, []);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
inputRef.current?.focus();
setOpen(true);
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
function selectItem(item: SearchItem) {
navigate(item.href);
setQuery("");
@@ -111,6 +123,9 @@ export function DashboardSearch({ className }: { className?: string }) {
aria-controls="dashboard-search-results"
aria-autocomplete="list"
/>
<kbd className="pointer-events-none absolute right-2 top-2 hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100 sm:flex">
<span className="text-xs">⌘</span>K
</kbd>
{showPanel && panelRect
? createPortal(
@@ -0,0 +1,166 @@
import { useEffect, useState } from "react";
import { api } from "@/api/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useAuth } from "@/context/AuthContext";
export function DashboardApiSettings() {
const { hasPermission } = useAuth();
const canEdit = hasPermission("settings.update");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
const [config, setConfig] = useState<any>(null);
useEffect(() => {
api.getConfigJson("api")
.then((res) => {
if (res.data) setConfig(res.data);
})
.catch(() => setMessage("Failed to load API config"))
.finally(() => setLoading(false));
}, []);
async function saveConfig() {
if (!canEdit || !config) return;
setSaving(true);
setMessage("");
try {
// Create a flat update payload for updateConfigFields
const updates = {
"api:API.Port": config.API?.Port,
"api:API.EmeraldAPIKey": config.API?.EmeraldAPIKey,
"api:API.TrustProxy": config.API?.TrustProxy,
"api:API.OAuth.Enabled": config.API?.OAuth?.Enabled,
"api:API.OAuth.ClientId": config.API?.OAuth?.ClientId,
"api:API.OAuth.ClientSecret": config.API?.OAuth?.ClientSecret,
"api:API.OAuth.RedirectUri": config.API?.OAuth?.RedirectUri,
};
await api.updateConfigFields(updates);
setMessage("API settings saved and backend restarted.");
} catch (err) {
setMessage(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
}
if (loading) return <div className="animate-pulse h-32 bg-secondary/20 rounded-md" />;
if (!config) return null;
return (
<div className="space-y-8">
<div className="space-y-4">
<div>
<p className="text-sm font-medium">Core API Settings</p>
<p className="text-xs text-muted-foreground">
Configure the internal API that powers this dashboard.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>API Port</Label>
<Input
type="number"
value={config.API?.Port || ""}
disabled={!canEdit}
onChange={(e) => setConfig({ ...config, API: { ...config.API, Port: parseInt(e.target.value) || 3000 }})}
className="border-border bg-secondary/30"
/>
</div>
<div className="space-y-2">
<Label>Emerald API Key</Label>
<Input
type="password"
value={config.API?.EmeraldAPIKey || ""}
disabled={!canEdit}
onChange={(e) => setConfig({ ...config, API: { ...config.API, EmeraldAPIKey: e.target.value }})}
className="border-border bg-secondary/30"
/>
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<Switch
checked={config.API?.TrustProxy || false}
disabled={!canEdit}
onCheckedChange={(c) => setConfig({ ...config, API: { ...config.API, TrustProxy: c }})}
/>
<Label className="text-sm">Trust Proxy (Enable if using Cloudflare/Nginx)</Label>
</div>
</div>
<div className="space-y-4 border-t border-border pt-6">
<div>
<p className="text-sm font-medium">OAuth Authentication</p>
<p className="text-xs text-muted-foreground">
Discord application credentials for dashboard login.
</p>
</div>
<div className="flex items-center gap-2 pb-2">
<Switch
checked={config.API?.OAuth?.Enabled || false}
disabled={!canEdit}
onCheckedChange={(c) => setConfig({ ...config, API: { ...config.API, OAuth: { ...config.API?.OAuth, Enabled: c } }})}
/>
<Label className="text-sm">Enable OAuth</Label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Client ID</Label>
<Input
value={config.API?.OAuth?.ClientId || ""}
disabled={!canEdit}
onChange={(e) => setConfig({ ...config, API: { ...config.API, OAuth: { ...config.API?.OAuth, ClientId: e.target.value } }})}
className="border-border bg-secondary/30"
/>
</div>
<div className="space-y-2">
<Label>Client Secret</Label>
<Input
type="password"
value={config.API?.OAuth?.ClientSecret || ""}
disabled={!canEdit}
onChange={(e) => setConfig({ ...config, API: { ...config.API, OAuth: { ...config.API?.OAuth, ClientSecret: e.target.value } }})}
className="border-border bg-secondary/30"
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>Redirect URI</Label>
<Input
value={config.API?.OAuth?.RedirectUri || ""}
disabled={!canEdit}
onChange={(e) => setConfig({ ...config, API: { ...config.API, OAuth: { ...config.API?.OAuth, RedirectUri: e.target.value } }})}
className="border-border bg-secondary/30"
/>
<p className="text-[10px] text-muted-foreground mt-1">
Must match the OAuth2 callback URL registered in the Discord Developer Portal exactly.
</p>
</div>
</div>
</div>
{canEdit ? (
<div className="flex items-center gap-2 border-t border-border pt-6">
<Button
type="button"
disabled={saving}
onClick={() => void saveConfig()}
>
{saving ? "Saving…" : "Save API Config"}
</Button>
{message ? (
<span className="text-xs text-muted-foreground">{message}</span>
) : null}
</div>
) : null}
</div>
);
}
@@ -105,61 +105,80 @@ export function DashboardBrandingSettings({ canEdit }: DashboardBrandingSettings
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 className="space-y-8">
{/* Text Branding */}
<div className="space-y-4">
<div>
<p className="text-sm font-medium">Branding Text</p>
<p className="text-xs text-muted-foreground">
Sidebar title and browser tab title — 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>
{canEdit ? (
<div className="flex items-center gap-2">
<Button
type="button"
disabled={saving || !textDirty}
onClick={() => void saveText()}
>
{saving ? "Saving…" : "Save titles"}
</Button>
{message && textDirty ? (
<span className="text-xs text-muted-foreground">{message}</span>
) : null}
</div>
) : null}
</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"
/>
{/* Favicon Branding */}
<div className="space-y-4 border-t border-border pt-6">
<div>
<p className="text-sm font-medium">Favicon</p>
<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>
<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">
<div className="flex flex-wrap items-center gap-4">
<div className="flex size-16 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"
className="size-full object-contain p-2"
/>
) : (
<Icon
icon={ImageUpload01Icon}
size={22}
className="text-muted-foreground"
size={24}
className="text-muted-foreground/50"
/>
)}
</div>
@@ -171,60 +190,57 @@ export function DashboardBrandingSettings({ canEdit }: DashboardBrandingSettings
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 ? (
<div className="flex flex-col gap-2">
{canEdit ? (
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
variant="secondary"
disabled={uploading}
onClick={() => void removeFavicon()}
onClick={() => fileRef.current?.click()}
>
Remove
{uploading ? "Uploading…" : "Upload Favicon"}
</Button>
) : null}
</>
) : (
<span className="text-xs text-muted-foreground">View only</span>
)}
{branding.hasFavicon ? (
<Button
type="button"
variant="destructive"
disabled={uploading}
onClick={() => void removeFavicon()}
>
Remove
</Button>
) : null}
</div>
) : (
<span className="text-xs text-muted-foreground">View only</span>
)}
{!textDirty && message ? (
<span className="text-xs text-muted-foreground">{message}</span>
) : null}
</div>
</div>
</div>
{/* Danger Zone */}
{canEdit ? (
<div className="flex flex-wrap gap-2">
<div className="space-y-4 border-t border-border pt-6">
<div>
<p className="text-sm font-medium">Danger Zone</p>
<p className="text-xs text-muted-foreground">
Reset all branding text and favicon to their original defaults.
</p>
</div>
<Button
type="button"
size="sm"
disabled={saving || !textDirty}
onClick={() => void saveText()}
>
{saving ? "Saving…" : "Save titles"}
</Button>
<Button
type="button"
variant="outline"
size="sm"
variant="destructive"
disabled={saving || uploading}
onClick={() => void resetAll()}
>
Reset branding
Reset Branding to Default
</Button>
</div>
) : null}
{message ? (
<p className="text-xs text-muted-foreground">{message}</p>
) : null}
</div>
);
}
+1 -1
View File
@@ -67,7 +67,7 @@ export const ROLE_HINTS: Record<DashboardRole, string> = {
export const CONFIG_FILE_LABELS: Record<ConfigFile, string> = {
supportbot: "Bot config",
"ticket-panel": "Ticket system",
"ticket-panel": "Ticket panel",
commands: "Commands",
messages: "Messages",
"supportbot-ai": "AI assistant",
+1 -1
View File
@@ -26,7 +26,7 @@ export interface PluginModule {
export const plugins: PluginModule[] = [
{
id: "tickets",
title: "Ticket system",
title: "Ticket panel",
description:
"Create and manage support tickets with departments, priorities, and AI assistance.",
icon: Ticket01Icon,
+223
View File
@@ -0,0 +1,223 @@
import { useState } from "react";
import { CommandIcon, SparklesIcon } from "@hugeicons/core-free-icons";
import { Icon } from "@/components/icon";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { api } from "@/api/client";
import { toast } from "sonner";
import { useAuth } from "@/context/AuthContext";
import { canEditConfig } from "@/lib/permissions";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { CheckCircle2Icon } from "lucide-react";
export function AddonBuilder() {
const { permissions } = useAuth();
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [restartMessage, setRestartMessage] = useState("");
const [commandName, setCommandName] = useState("");
const [description, setDescription] = useState("");
const [permission, setPermission] = useState("Administrator");
const [embedTitle, setEmbedTitle] = useState("");
const [embedDescription, setEmbedDescription] = useState("");
const [embedColor, setEmbedColor] = useState("General");
const canEdit = canEditConfig(permissions, "supportbot");
const handleBuild = async () => {
if (!commandName.match(/^[a-z0-9_-]+$/i)) {
toast.error("Command name can only contain letters, numbers, underscores and dashes.");
return;
}
setLoading(true);
setSuccess(false);
try {
// NOTE: the endpoint isn't added to client.ts yet, we'll do that next
const res = await (api as any).buildAddon({
name: commandName.toLowerCase(),
description: description || "A custom command",
permission,
embed: {
title: embedTitle,
description: embedDescription || "Hello from your custom command!",
color: embedColor
}
});
setSuccess(true);
setRestartMessage(res.message || "Restart required.");
toast.success(`Addon ${commandName}.js created successfully!`);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to build addon");
} finally {
setLoading(false);
}
};
if (!canEdit) {
return (
<div className="flex h-[50vh] items-center justify-center text-muted-foreground">
You don't have permission to build addons.
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Addon Builder</h1>
<p className="text-muted-foreground">
Visually scaffold a custom slash command addon for your bot.
</p>
</div>
{success ? (
<Alert className="border-green-500/20 bg-green-500/10 text-green-600 dark:text-green-400">
<CheckCircle2Icon className="h-4 w-4" />
<AlertDescription className="ml-2">
Successfully built <strong>{commandName}.js</strong>! {restartMessage}
</AlertDescription>
</Alert>
) : null}
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">Command Details</CardTitle>
<CardDescription>Configure how users trigger the command.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Command Name</Label>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">/</span>
<Input
placeholder="ping"
value={commandName}
onChange={(e) => setCommandName(e.target.value.replace(/[^a-z0-9_-]/gi, '').toLowerCase())}
/>
</div>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input
placeholder="Replies with a custom embed"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Required Permission</Label>
<Select value={permission} onValueChange={setPermission}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">@everyone</SelectItem>
<SelectItem value="Administrator">Administrator</SelectItem>
<SelectItem value="ManageMessages">Manage Messages</SelectItem>
<SelectItem value="ManageGuild">Manage Server</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Response Embed</CardTitle>
<CardDescription>What the bot will reply with.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Embed Title (Optional)</Label>
<Input
placeholder="Hello!"
value={embedTitle}
onChange={(e) => setEmbedTitle(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Embed Description</Label>
<Textarea
placeholder="Type your message here..."
className="resize-none"
rows={4}
value={embedDescription}
onChange={(e) => setEmbedDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Embed Color</Label>
<Select value={embedColor} onValueChange={setEmbedColor}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="General">Theme General</SelectItem>
<SelectItem value="Success">Theme Success</SelectItem>
<SelectItem value="Warning">Theme Warning</SelectItem>
<SelectItem value="Error">Theme Error</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
</div>
<div className="space-y-6">
<Card className="sticky top-6 border-primary/20">
<CardHeader>
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Icon icon={SparklesIcon} size={16} className="text-primary" />
Live Preview
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border bg-[#313338] p-4 font-sans text-sm text-[#dbdee1]">
<div className="flex gap-4">
<div className="size-10 shrink-0 rounded-full bg-indigo-500 flex items-center justify-center font-bold text-white">
B
</div>
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium text-white">SupportBot</span>
<span className="text-[10px] text-white font-medium uppercase px-1.5 py-0.5 rounded bg-[#5865F2] flex items-center gap-1">
<CheckCircle2Icon size={10} /> BOT
</span>
<span className="text-xs text-[#949ba4]">Today at 12:00 PM</span>
</div>
<div className="mt-2 rounded bg-[#2b2d31] border-l-4 border-l-[#5865F2] px-4 py-3 max-w-[432px]">
{embedTitle && <div className="mb-1 font-semibold text-white">{embedTitle}</div>}
<div className="whitespace-pre-wrap">
{embedDescription || "Hello from your custom command!"}
</div>
</div>
</div>
</div>
</div>
</CardContent>
<CardFooter>
<Button
className="w-full gap-2"
size="lg"
disabled={!commandName || loading}
onClick={handleBuild}
>
<Icon icon={CommandIcon} size={18} />
{loading ? "Building..." : "Generate Addon"}
</Button>
</CardFooter>
</Card>
</div>
</div>
</div>
);
}
+31 -26
View File
@@ -83,14 +83,14 @@ export default function Addons() {
<div>
<h1 className="text-2xl font-bold tracking-tight">Addons</h1>
<p className="text-muted-foreground">
Install community addons from{" "}
Install addons from the{" "}
<a
href={repository || "https://github.com/Emerald-Services/Addons"}
href={repository || "https://emeraldsrv.dev"}
target="_blank"
rel="noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
Emerald-Services/Addons
Emerald Services Marketplace
</a>
. Enable addons under{" "}
<Link
@@ -148,22 +148,25 @@ export default function Addons() {
/>
{addon.name}
</CardTitle>
{addon.installed ? (
<Badge variant="secondary" className="shrink-0 gap-1">
<Icon icon={Tick02Icon} size={14} />
Installed
<div className="flex gap-2">
<Badge variant="outline" className="shrink-0 uppercase tracking-widest text-[10px]">
{addon.price === 0 || !addon.price ? "Free" : `$${addon.price}`}
</Badge>
) : null}
{addon.installed ? (
<Badge variant="secondary" className="shrink-0 gap-1">
<Icon icon={Tick02Icon} size={14} />
Installed
</Badge>
) : null}
</div>
</div>
<CardDescription>{addon.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-xs text-muted-foreground">
<p>
{addon.jsFiles.length} command file
{addon.jsFiles.length === 1 ? "" : "s"}:{" "}
<span className="font-mono">{addon.jsFiles.join(", ")}</span>
</p>
{addon.configFiles.length > 0 ? (
{addon.is_external ? (
<p>Available on: <span className="font-medium text-foreground">{addon.external_store || "External Store"}</span></p>
) : null}
{addon.configFiles && addon.configFiles.length > 0 ? (
<p>
Config:{" "}
{addon.configFiles.map((file, i) => (
@@ -181,16 +184,18 @@ export default function Addons() {
) : null}
</CardContent>
<CardFooter className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<a
href={addon.repositoryUrl}
target="_blank"
rel="noreferrer"
>
View on GitHub
</a>
</Button>
{addon.installed && addon.configFiles[0] ? (
{addon.is_external ? (
<Button variant="outline" size="sm" asChild>
<a
href={addon.repositoryUrl}
target="_blank"
rel="noreferrer"
>
View Store
</a>
</Button>
) : null}
{addon.installed && addon.configFiles && addon.configFiles[0] ? (
<Button size="sm" variant="secondary" asChild>
<Link
to={`/configs?file=${addonConfigFileParam(addon.configFiles[0])}`}
@@ -203,7 +208,7 @@ export default function Addons() {
<Button size="sm" disabled variant="secondary">
Installed
</Button>
) : (
) : !addon.is_external ? (
<Button
size="sm"
disabled={!canInstall || installing === addon.id}
@@ -212,7 +217,7 @@ export default function Addons() {
<Icon icon={Download04Icon} size={14} className="mr-1" />
{installing === addon.id ? "Installing…" : "Install"}
</Button>
)}
) : null}
</CardFooter>
</Card>
))}
+1 -1
View File
@@ -19,7 +19,7 @@ const CONFIG_FILES = api.listConfigs();
const FILE_TITLES: Record<(typeof CONFIG_FILES)[number], string> = {
supportbot: "Bot config",
"ticket-panel": "Ticket system",
"ticket-panel": "Ticket panel",
commands: "Commands",
messages: "Messages",
"supportbot-ai": "AI assistant",
+80 -54
View File
@@ -28,11 +28,12 @@ import {
} from "@/lib/accent-theme";
import { normalizeHex } from "@/lib/normalize-hex";
import { DashboardBrandingSettings } from "@/components/settings/dashboard-branding-settings";
import { DashboardApiSettings } from "@/components/settings/dashboard-api-settings";
export default function Settings() {
const { hasPermission } = useAuth();
const canUpdate = hasPermission("settings.update");
const [check, setCheck] = useState<UpdateCheckResult | null>(null);
const [checks, setChecks] = useState<UpdateCheckResult[] | null>(null);
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState(false);
const [error, setError] = useState("");
@@ -47,7 +48,7 @@ export default function Settings() {
setError("");
try {
const res = await api.checkForUpdates();
if (res.data) setCheck(res.data);
if (res.data) setChecks(res.data);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not check for updates");
} finally {
@@ -59,13 +60,13 @@ export default function Settings() {
loadCheck();
}, [loadCheck]);
async function handleUpdate() {
if (!check?.updateAvailable) return;
async function handleUpdate(check: UpdateCheckResult) {
if (!check.updateAvailable) return;
setUpdating(true);
setError("");
setNotice("");
try {
const res = await api.runUpdate(check.latest);
const res = await api.runUpdate(check.id, check.latest, check.zipUrl);
setNotice(
res.message ||
"Update installed. Restart your server (stop and run npm start again) to finish applying changes.",
@@ -162,44 +163,88 @@ export default function Settings() {
</CardContent>
</Card>
<Card className="border-border bg-card">
<CardHeader>
<CardTitle className="text-lg">API Configuration</CardTitle>
<CardDescription>
Configure the internal API settings, Emerald API key, and OAuth.
</CardDescription>
</CardHeader>
<CardContent>
<DashboardApiSettings />
</CardContent>
</Card>
<Card className="border-border bg-card">
<CardHeader>
<CardTitle className="text-lg">Software updates</CardTitle>
<CardDescription>
Checks{" "}
<a
href="https://github.com/C-h-a-r/SupportBot-Dashboard"
target="_blank"
rel="noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
SupportBot-Dashboard
</a>{" "}
on the release branch.
Manage updates for the bot and dashboard.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<Skeleton className="h-24 w-full" />
) : check ? (
<div className="space-y-3 rounded-lg border border-border bg-secondary/20 p-4">
<div className="flex flex-wrap gap-x-6 gap-y-2 text-sm">
<p>
<span className="text-muted-foreground">Installed: </span>
<span className="font-mono font-medium">v{check.current}</span>
</p>
<p>
<span className="text-muted-foreground">Latest: </span>
<span className="font-mono font-medium">v{check.latest}</span>
</p>
</div>
{check.updateAvailable ? (
<p className="text-sm text-amber-500">
A newer version is available. Update downloads bot files while keeping your Configs and Data.
</p>
) : (
<p className="text-sm text-emerald-500">You are on the latest release.</p>
)}
) : checks && checks.length > 0 ? (
<div className="space-y-4">
{checks.map((check) => (
<div key={check.id} className="space-y-3 rounded-lg border border-border bg-secondary/20 p-4">
<div className="flex items-center justify-between">
<p className="font-medium text-sm">
<a
href={check.repository}
target="_blank"
rel="noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
{check.name}
</a>{" "}
({check.branch} branch)
</p>
<div className="flex gap-x-4 text-sm">
<p>
<span className="text-muted-foreground">Installed: </span>
<span className="font-mono font-medium">v{check.current}</span>
</p>
<p>
<span className="text-muted-foreground">Latest: </span>
<span className="font-mono font-medium">v{check.latest}</span>
</p>
</div>
</div>
<div className="flex items-center justify-between">
{check.updateAvailable ? (
<p className="text-sm text-amber-500">
A newer version is available. Update downloads files while keeping your Configs and Data.
</p>
) : (
<p className="text-sm text-emerald-500">You are on the latest release.</p>
)}
{check.updateAvailable && canUpdate ? (
<Button type="button" size="sm" onClick={() => void handleUpdate(check)} disabled={updating}>
{updating ? (
<Icon
icon={Loading03Icon}
size={16}
className="mr-2 animate-spin"
/>
) : (
<Icon icon={Download04Icon} size={16} className="mr-2" />
)}
{updating ? "Installing…" : "Update now"}
</Button>
) : null}
{check.updateAvailable && !canUpdate ? (
<p className="text-xs text-muted-foreground">
You cannot install updates with your role.
</p>
) : null}
</div>
</div>
))}
</div>
) : null}
@@ -215,7 +260,7 @@ export default function Settings() {
</Alert>
) : null}
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2 pt-2">
<Button
type="button"
variant="outline"
@@ -225,25 +270,6 @@ export default function Settings() {
<Icon icon={RefreshIcon} size={16} className="mr-2" />
Check again
</Button>
{check?.updateAvailable && canUpdate ? (
<Button type="button" onClick={handleUpdate} disabled={updating}>
{updating ? (
<Icon
icon={Loading03Icon}
size={16}
className="mr-2 animate-spin"
/>
) : (
<Icon icon={Download04Icon} size={16} className="mr-2" />
)}
{updating ? "Installing…" : "Update now"}
</Button>
) : null}
{check?.updateAvailable && !canUpdate ? (
<p className="text-xs text-muted-foreground">
You can check for updates but cannot install them with your role.
</p>
) : null}
</div>
</CardContent>
</Card>