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:
1 parent
24ccc179a6
commit
1a6b360701
13 files changed
+720
-213
No files matched your search
+11
-1
@@ -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>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user