feat(v3.13.0): settings live DB reads, remove Directus/Appwrite, admin stats

Settings fixes:
- MAX_SITES_PER_USER, USER_RATE_LIMIT_PER_MIN/HR now read from DB settings
  table (DB > ENV > default), so dashboard/settings changes apply without
  restart. Sync cache refreshed on every save or delete.
- /api/me reports the live DB value for max_sites_per_user.

Admin improvements:
- Admin users bypass per-user rate limiting entirely (role=admin or ADMIN_EMAILS).
- Admin Overview now shows platform stats: registered users, new users (7d),
  total user sites, available tools.

Plugin cleanup:
- Appwrite and Directus plugins removed from the active registry (8 plugins
  now: WordPress, WooCommerce, WordPress Specialist, Gitea, n8n, Supabase,
  OpenPanel, Coolify). Plugin code is retained for future re-enabling.
- Settings page plugin visibility list updated to match.

Mobile onboarding:
- Stepper steps on narrow viewports stack vertically with correct full border
  and rounded corners on each step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 23:33:20 +02:00
parent f203ca88de
commit 43fd2201a0
223 changed files with 36183 additions and 4115 deletions

View File

@@ -0,0 +1,65 @@
// Logo + wordmark — uses the original brand mark from core/templates/static/logo.svg.
// Path data is inlined so we can drive fills with CSS variables (theme-aware).
// `original=true` swaps to the source palette (#51b9f4 / #fec13d) for marketing.
type LogoProps = {
size?: number;
className?: string;
/** When true, use the original brand colors instead of the theme tokens. */
original?: boolean;
};
export function Logo({ size = 28, className, original = false }: LogoProps) {
const a = original ? "#51b9f4" : "var(--brand-500)";
const b = original ? "#fec13d" : "var(--accent-500)";
return (
<svg
width={size}
height={size}
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
aria-label="MCP Hub"
className={className}
>
<path
fill={a}
d="m496.4 11.5q5.4-0.4 10.9-0.5 5.4-0.1 10.9 0.1 5.4 0.2 10.8 0.7 5.5 0.4 10.9 1.2c56.2 7.7 99.4 36 133.3 81.3 31 41.4 41 94.3 34.8 144.5-6.9 54.7-31.6 89.2-71.8 124.8-9.7 8.6-19.4 21-29.9 28.7 8 7.2 17.5 17 25.1 24.8 8.6-11.9 22.8-24.8 33.4-35.4 10.6-10.1 20.7-22.2 32.2-31.2 44.5-34.8 109.1-43.7 163.2-31.3 97.6 22.4 162.8 115.9 151.4 215.3-12.8 111.6-114.9 189.3-225.7 173.7-56.9-8-89.1-29.9-126.7-72.5-8.4-9.5-20.3-19-28.1-29.2-3 4.4-20.1 21-24.7 25.2q0.8 0.7 1.6 1.4c10.3 8.9 20.5 20.3 30 29.2 31.4 29.2 55.6 55.7 65.1 98.8 3 13.7 4.9 22.1 6.1 36.6 4.1 52.5-11.2 105-46 144.9-28.1 32.3-64.1 56.5-106.4 64.9-9.6 2-17.3 3.9-27.3 4.6-11.3 1.4-27.3 0.9-38.7-0.3-55.1-6.2-100.6-31.3-135.2-74.7-6.8-8.5-12.8-17.6-18.1-27.2-5.2-9.5-9.7-19.4-13.3-29.7-3.6-10.3-6.3-20.8-8.1-31.6-1.9-10.7-2.8-21.5-2.8-32.4-0.3-41.9 11.1-88.4 38.8-121 5-5.8 10.5-11.8 16.2-17.1 15.8-14.8 31.2-33.3 48-46.7-7.7-8-17.1-16.5-24.2-24.6-8.4 9.8-18.5 19-27.4 28.3-10.5 10.8-20.7 21.7-31.8 31.9-36.3 32.8-85.8 44.5-133.6 42.7-12.8-0.5-25.5-2.3-38-5.3-12.5-3-24.6-7.3-36.2-12.7-11.7-5.4-22.7-11.9-33.1-19.4-10.3-7.6-19.9-16.2-28.6-25.6-46.8-50.7-62.6-116.6-46.9-183.1 8.7-41.1 30.3-72.1 60.7-100.4 64.9-60.6 180.9-67.1 250.4-10.7 10.9 8.8 22.5 21.5 32.4 31.7 10.5 10.9 21.9 21.7 32.1 33 8.1-11 14.4-15.9 23.9-24.9-37.7-41.9-77.8-63.2-94-122.1-17.9-65.2-8.7-131.9 34.5-185.2 37.9-46.8 81.1-66.8 139.9-73.5z"
/>
<path
fill={b}
d="M210 432c44 0.6 79 36 79 80 0 44-36 79-80 79-44 0-80-36-80-80 0-44 37-80 81-79z m294-302c44-5 83 27 87 71 5 44-27 83-71 87-44 4-83-27-87-71-4-44 27-83 71-87z m0 606c44-5 83 27 88 70 5 44-27 83-71 88-44 5-83-27-88-71-5-43 27-83 71-87z m302-302c44-5 83 26 88 70 5 44-26 83-70 88-44 5-83-26-88-70-5-44 26-83 70-88z"
/>
</svg>
);
}
type WordmarkProps = {
/** Logo mark size in px. The wordmark text auto-scales to feel proportional. */
size?: number;
/** Hide the text — useful when the sidebar is collapsed. */
iconOnly?: boolean;
className?: string;
};
export function LogoWordmark({ size = 28, iconOnly = false, className }: WordmarkProps) {
// The brand "MCP Hub" must always read left-to-right regardless of page
// direction — flipping it to "Hub MCP" in RTL would mangle the proper noun.
// `dir="ltr"` pins the inline flow, then `unicode-bidi: bidi-override` (set
// via .logo-text in CSS) keeps the order stable even when the surrounding
// paragraph is RTL.
return (
<span
className={`logo-wrap${className ? ` ${className}` : ""}`}
style={{ ["--logo-size" as string]: `${size}px` }}
dir="ltr"
>
<Logo size={size} />
{iconOnly ? null : (
<span className="logo-text" aria-hidden={false}>
<span className="logo-text-primary">MCP</span>
<span className="logo-text-secondary">Hub</span>
</span>
)}
</span>
);
}

View File

@@ -0,0 +1,89 @@
import { useEffect, useRef, useState } from "react";
import { Icons } from "./icons";
import { useT } from "../lib/i18n";
import { useUiStore, type ThemePref } from "../lib/store";
const THEME_ORDER: ThemePref[] = ["light", "dark", "system"];
export function PublicControls({ compact = false }: { compact?: boolean }) {
const t = useT();
const [langOpen, setLangOpen] = useState(false);
const langMenuRef = useRef<HTMLDivElement | null>(null);
const { theme, setTheme, lang, setLang } = useUiStore();
const ThemeIcon = theme === "dark" ? Icons.moon : theme === "light" ? Icons.sun : Icons.monitor;
const cycleTheme = () => {
const idx = THEME_ORDER.indexOf(theme);
setTheme(THEME_ORDER[(idx + 1) % THEME_ORDER.length]);
};
useEffect(() => {
if (!langOpen) return;
const onPointerDown = (event: PointerEvent) => {
if (!langMenuRef.current?.contains(event.target as Node)) {
setLangOpen(false);
}
};
window.addEventListener("pointerdown", onPointerDown);
return () => window.removeEventListener("pointerdown", onPointerDown);
}, [langOpen]);
const chooseLang = (next: "en" | "fa") => {
setLang(next);
setLangOpen(false);
};
return (
<div className="public-controls" aria-label={t("topbar.change_language", "Change language")}>
<div className="lang-menu-wrap" ref={langMenuRef}>
<button
type="button"
className="btn btn-ghost btn-sm topbar-lang-btn"
onClick={() => setLangOpen((open) => !open)}
title={t("topbar.change_language", "Change language")}
aria-label={t("topbar.change_language", "Change language")}
aria-haspopup="menu"
aria-expanded={langOpen}
style={{ padding: compact ? "6px 8px" : "6px 10px" }}
>
<Icons.globe style={{ width: 14, height: 14 }} />
<span style={{ fontSize: 11.5, fontWeight: 600 }}>{lang === "fa" ? "FA" : "EN"}</span>
</button>
{langOpen ? (
<div className="lang-menu" role="menu" aria-label={t("topbar.change_language", "Change language")}>
<button
type="button"
role="menuitemradio"
aria-checked={lang === "en"}
className={lang === "en" ? "is-active" : ""}
onClick={() => chooseLang("en")}
>
<span>English</span>
{lang === "en" ? <Icons.check style={{ width: 14, height: 14 }} /> : null}
</button>
<button
type="button"
role="menuitemradio"
aria-checked={lang === "fa"}
className={lang === "fa" ? "is-active" : ""}
onClick={() => chooseLang("fa")}
>
<span>فارسی</span>
{lang === "fa" ? <Icons.check style={{ width: 14, height: 14 }} /> : null}
</button>
</div>
) : null}
</div>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={cycleTheme}
title={`${t("topbar.cycle_theme", "Switch theme")} · ${t(`theme.${theme}`, theme)}`}
aria-label={t("topbar.cycle_theme", "Switch theme")}
style={{ padding: 6 }}
>
<ThemeIcon style={{ width: 16, height: 16 }} />
</button>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { useEffect } from "react";
import { Outlet, useLocation } from "react-router-dom";
import { Sidebar } from "./Sidebar";
import { useSession } from "../lib/queries";
import { useUiStore } from "../lib/store";
export function Shell() {
const { data: session } = useSession();
const collapsed = useUiStore((s) => s.sidebarCollapsed);
const mobileOpen = useUiStore((s) => s.sidebarMobileOpen);
const setMobileOpen = useUiStore((s) => s.setSidebarMobileOpen);
const location = useLocation();
// Auto-close the mobile drawer whenever the route changes — tapping a nav
// item should feel like "take me there + dismiss the drawer".
useEffect(() => {
if (mobileOpen) setMobileOpen(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname]);
// Prevent body scroll while the drawer is open (mobile UX expectation).
useEffect(() => {
if (!mobileOpen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [mobileOpen]);
const classes = [
"shell",
collapsed ? "is-collapsed" : "",
mobileOpen ? "is-mobile-open" : "",
]
.filter(Boolean)
.join(" ");
return (
<div className={classes}>
<Sidebar session={session} />
<button
type="button"
aria-label="Close menu"
className="sidebar-backdrop"
onClick={() => setMobileOpen(false)}
tabIndex={mobileOpen ? 0 : -1}
/>
<main>
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,165 @@
import { NavLink } from "react-router-dom";
import { Icons, type IconName } from "./icons";
import { LogoWordmark } from "./Logo";
import { Avatar } from "./primitives";
import type { Session } from "../lib/types";
import { useSites, useUserKeys, useAdminApiKeys } from "../lib/queries";
import { useT } from "../lib/i18n";
import { useUiStore } from "../lib/store";
import { fmtInt } from "../lib/format";
type NavItem = { id: string; label: string; icon: IconName; to: string; count?: number; adminOnly?: boolean };
const SUPPORT_URL = "https://nowpayments.io/donation/airano";
export function Sidebar({ session }: { session: Session | undefined }) {
const t = useT();
const collapsed = useUiStore((s) => s.sidebarCollapsed);
const lang = useUiStore((s) => s.lang);
const isAdmin = session?.is_admin ?? false;
const isAdminKeySession = isAdmin && session?.type !== "oauth_user";
const sites = useSites();
const userKeys = useUserKeys();
const adminKeys = useAdminApiKeys();
// NavLink `to` props are relative to the BrowserRouter basename
// (`/dashboard`) — do NOT prefix them or you'll get
// `/dashboard/dashboard/...`.
const groups: { label: string; items: NavItem[] }[] = [
{
label: t("nav.manage", "Manage"),
items: [
{ id: "overview", label: t("nav.overview", "Overview"), icon: "home", to: "/overview" },
{
id: "sites",
label: t("nav.sites", "Sites"),
icon: "sites",
to: "/sites",
count: sites.data?.length,
},
{ id: "connect", label: t("nav.connect", "Connect"), icon: "plug", to: "/connect" },
],
},
{
label: t("nav.access", "Access"),
items: [
{
id: "apikeys",
label: t("nav.api_keys", "API Keys"),
icon: "key",
to: "/api-keys",
count: isAdminKeySession ? adminKeys.data?.length : userKeys.data?.length,
},
],
},
...(isAdmin
? [
{
label: t("nav.observability", "Observability"),
items: [
{
id: "health",
label: t("nav.health", "Health"),
icon: "activity" as IconName,
to: "/health",
adminOnly: true,
},
{
id: "audit",
label: t("nav.audit", "Audit Logs"),
icon: "logs" as IconName,
to: "/audit-logs",
adminOnly: true,
},
],
},
]
: []),
{
label: t("nav.account", "Account"),
items: [
{ id: "settings", label: t("nav.settings", "Settings"), icon: "settings", to: "/settings" },
],
},
];
return (
<aside data-collapsed={collapsed ? "true" : "false"}>
<NavLink
to="/landing"
className="logo-link"
style={{ padding: "4px 8px 16px", display: "flex", justifyContent: collapsed ? "center" : "flex-start" }}
title="MCP Hub"
>
<LogoWordmark size={28} iconOnly={collapsed} />
</NavLink>
{!collapsed && (
<div className="jumpto" aria-hidden="false">
<Icons.search style={{ width: 14, height: 14 }} />
<span style={{ flex: 1 }}>{t("nav.jump_to", "Jump to…")}</span>
<span className="kbd">K</span>
</div>
)}
{groups.map((g) => (
<div key={g.label} className="nav-group">
{!collapsed && (
<div className="eyebrow nav-group-label">{g.label}</div>
)}
{g.items.map((it) => {
const Ic = Icons[it.icon];
return (
<NavLink
key={it.id}
to={it.to}
className={({ isActive }) => `nav-item ${isActive ? "is-active" : ""}`}
title={collapsed ? it.label : undefined}
aria-label={it.label}
>
<Ic />
{!collapsed && <span className="nav-item-label">{it.label}</span>}
{!collapsed && it.count != null ? <span className="count">{fmtInt(it.count, lang)}</span> : null}
</NavLink>
);
})}
</div>
))}
<div className="sidebar-footer">
<a
href={SUPPORT_URL}
target="_blank"
rel="noopener noreferrer"
className="nav-item sidebar-support-link"
title={collapsed ? t("support_mcphub", "Support MCP Hub") : undefined}
aria-label={t("support_mcphub", "Support MCP Hub")}
>
<Icons.link />
{!collapsed && <span className="nav-item-label">{t("support_mcphub", "Support MCP Hub")}</span>}
</a>
<div className="sidebar-user">
<Avatar name={session?.name || session?.email || "User"} size={30} />
{!collapsed && (
<div className="sidebar-user-meta">
<div className="sidebar-user-name">{session?.name || "User"}</div>
<div className="caption sidebar-user-email">{session?.email || ""}</div>
</div>
)}
{!collapsed && (
<a
href="/dashboard/logout"
className="btn btn-ghost btn-sm"
style={{ padding: 6 }}
title={t("nav.logout", "Log out")}
aria-label={t("nav.logout", "Log out")}
>
<Icons.logout style={{ width: 14, height: 14 }} />
</a>
)}
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,487 @@
import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { Card, Btn } from "./primitives";
import { Icons } from "./icons";
import {
usePluginCatalog,
useCreateSite,
useUpdateSite,
type PluginFieldDef,
} from "../lib/queries";
import { useT } from "../lib/i18n";
import { useUiStore } from "../lib/store";
import type { Site } from "../lib/types";
type Mode = "create" | "edit";
function advancedFieldsLabel(
showAdvanced: boolean,
pluginName: string | undefined,
fields: PluginFieldDef[],
t: (key: string, fallback?: string) => string,
): string {
const advanced = fields.filter((field) => field.advanced);
const service = pluginName ?? t("sites.selected_service", "selected service");
const fieldNames = advanced
.slice(0, 3)
.map((field) => field.label)
.join(", ");
const extra = advanced.length > 3 ? ` +${advanced.length - 3}` : "";
const suffix = fieldNames ? `: ${fieldNames}${extra}` : "";
return showAdvanced
? t("sites.hide_advanced_for_service", "Hide advanced {service} fields").replace("{service}", service)
: t("sites.show_advanced_for_service", "Show advanced {service} fields").replace("{service}", service) + suffix;
}
function setupGuidance(
pluginType: string,
t: (key: string, fallback?: string) => string,
): { title: string; items: string[] } | null {
if (pluginType === "wordpress" || pluginType === "wordpress_specialist") {
return {
title:
pluginType === "wordpress_specialist"
? t("sites.guidance.wordpress_specialist_title", "WordPress Specialist requirements")
: t("sites.guidance.wordpress_title", "WordPress requirements"),
items: [
t(
"sites.guidance.wp_username",
"Username: WordPress admin username that owns the Application Password. Required.",
),
t(
"sites.guidance.wp_app_password",
"Application Password: WP Admin -> Users -> Profile -> Application Passwords. User must have manage_options. Required.",
),
t(
"sites.guidance.bridge_version",
"Airano MCP Bridge v2.11.0+ is recommended for companion-backed tools.",
),
t(
"sites.guidance.bridge_lag",
"The WordPress.org plugin page can lag behind repository builds while publishing/review completes; do not assume the newest repo feature is already available there.",
),
t(
"sites.guidance.companion_copy",
"Airano MCP Bridge — companion plugin (optional but recommended). Installing it unlocks larger uploads, unified site-health snapshot, cache purge, transient flush, bulk meta writes, structured export, capability probe, and audit-hook webhooks. Without it, basic tools still work but these features remain unavailable.",
),
],
};
}
if (pluginType === "woocommerce") {
return {
title: t("sites.guidance.woocommerce_title", "WooCommerce requirements"),
items: [
t(
"sites.guidance.wc_consumer_key",
"Consumer Key: WooCommerce -> Settings -> Advanced -> REST API -> Add Key. Read/Write permission. Required.",
),
t(
"sites.guidance.wc_consumer_secret",
"Consumer Secret: shown once, starts with cs_, save immediately. Required.",
),
t(
"sites.guidance.wc_no_extra_key",
"No extra API key field exists for WooCommerce REST auth.",
),
t(
"sites.guidance.wc_media_username",
"WordPress Username for media tools: only required for AI/media tools like upload_and_attach_to_product, attach_media_to_product, set_featured_image, generate_and_upload_image with attach_to_post. Optional.",
),
t(
"sites.guidance.wc_media_password",
"WordPress Application Password for media tools: required only for WC media uploads to /wp/v2/media; Consumer Key/Secret do not work for that. Optional.",
),
t(
"sites.guidance.bridge_version",
"Airano MCP Bridge v2.11.0+ is recommended for companion-backed tools.",
),
t(
"sites.guidance.bridge_lag",
"The WordPress.org plugin page can lag behind repository builds while publishing/review completes; do not assume the newest repo feature is already available there.",
),
t(
"sites.guidance.companion_copy",
"Airano MCP Bridge — companion plugin (optional but recommended). Installing it unlocks larger uploads, unified site-health snapshot, cache purge, transient flush, bulk meta writes, structured export, capability probe, and audit-hook webhooks. Without it, basic tools still work but these features remain unavailable.",
),
],
};
}
return null;
}
function supportsAiImageGeneration(pluginType: string): boolean {
return pluginType === "wordpress" || pluginType === "woocommerce";
}
// Replaces the legacy `/dashboard-legacy/sites/add` and
// `/dashboard-legacy/sites/:id/edit` Jinja round-trips with a
// native SPA dialog. On edit, credentials are NOT prefilled (the API never
// returns them); leave the field blank to keep the existing secret, or
// type a new value to replace it. We pass credentials as a partial object
// so unset fields are preserved server-side (PATCH semantics).
export function SiteFormDialog({
mode,
site,
onCancel,
onDone,
}: {
mode: Mode;
site?: Site;
onCancel: () => void;
onDone: () => void;
}) {
const t = useT();
const lang = useUiStore((s) => s.lang);
const setToast = useUiStore((s) => s.setToast);
const plugins = usePluginCatalog();
const create = useCreateSite();
const update = useUpdateSite(site?.id ?? "");
const [pluginType, setPluginType] = useState<string>(site?.plugin_type ?? "");
const [alias, setAlias] = useState<string>(site?.alias ?? "");
const [url, setUrl] = useState<string>(site?.url ?? "");
const [creds, setCreds] = useState<Record<string, string>>({});
const [showAdvanced, setShowAdvanced] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Default the plugin type to the first public one once the catalog loads
// (creating mode only — edit mode locks the type).
useEffect(() => {
if (mode !== "create") return;
if (pluginType) return;
if (!plugins.data || plugins.data.length === 0) return;
setPluginType(plugins.data[0].type);
}, [mode, pluginType, plugins.data]);
const currentPlugin = useMemo(
() => (plugins.data ?? []).find((p) => p.type === pluginType),
[plugins.data, pluginType],
);
const guidance = useMemo(() => setupGuidance(pluginType, t), [pluginType, t]);
const showAiImageSetup = supportsAiImageGeneration(pluginType);
const fields: PluginFieldDef[] = currentPlugin?.fields ?? [];
const advancedFields = fields.filter((f) => f.advanced);
const requiredFieldsMissing =
mode === "create" &&
fields.filter((f) => f.required).some((f) => !creds[f.name]?.trim());
const baseFieldsMissing =
mode === "create" && (!alias.trim() || !url.trim() || !pluginType);
const onSubmit = async () => {
if (submitting) return;
setError(null);
setSubmitting(true);
try {
// Drop blank credential fields — on edit, the backend keeps the
// stored value when the field is omitted from the PATCH payload.
const cleanedCreds: Record<string, string> = {};
Object.entries(creds).forEach(([k, v]) => {
if (v && v.trim()) cleanedCreds[k] = v.trim();
});
if (mode === "create") {
await create.mutateAsync({
plugin_type: pluginType,
alias: alias.trim(),
url: url.trim(),
credentials: cleanedCreds,
});
setToast(t("sites.toast_created", "Site created"));
} else {
// Edit: PATCH /api/sites/{id}. URL + alias may change; credentials
// only update when the user types something into a field.
await update.mutateAsync({
alias: alias.trim() || undefined,
url: url.trim() || undefined,
credentials: Object.keys(cleanedCreds).length > 0 ? cleanedCreds : undefined,
});
setToast(t("sites.toast_updated", "Site updated"));
}
onDone();
} catch (e: any) {
const msg = e?.body?.error ?? e?.message ?? String(e);
setError(msg);
} finally {
setSubmitting(false);
}
};
return (
<div className="dialog-backdrop" style={backdropStyle} onClick={onCancel}>
<div
className="dialog"
style={dialogStyle}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<Card style={{ width: "100%" }}>
<div style={headerStyle}>
<div className="h-2" style={{ margin: 0 }}>
{mode === "create"
? t("sites.dialog_add_title", "Add a site")
: t("sites.dialog_edit_title", "Edit site")}
</div>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={onCancel}
aria-label={t("cancel", "Cancel")}
style={{ padding: 6 }}
>
<Icons.x style={{ width: 16, height: 16 }} />
</button>
</div>
<div style={bodyStyle}>
{/* Plugin picker — locked on edit since changing plugin_type
would invalidate stored credentials. */}
<div className="field">
<label>{t("sites.field_plugin_type", "Plugin")}</label>
{plugins.isLoading ? (
<div className="shimmer" style={{ height: 36, borderRadius: 8 }} />
) : mode === "edit" ? (
<input
className="input"
value={currentPlugin?.name ?? pluginType}
disabled
/>
) : (
<select
className="input"
value={pluginType}
onChange={(e) => {
setPluginType(e.target.value);
setCreds({});
}}
disabled={submitting}
>
{(plugins.data ?? []).map((p) => (
<option key={p.type} value={p.type}>
{p.name}
</option>
))}
</select>
)}
</div>
{guidance ? (
<div className="alert alert-info" style={guidanceStyle}>
<Icons.info style={{ width: 16, height: 16, flexShrink: 0, marginTop: 2 }} />
<div>
<div style={{ fontWeight: 600, marginBottom: 6 }}>{guidance.title}</div>
<ul style={guidanceListStyle}>
{guidance.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
</div>
) : null}
{showAiImageSetup ? (
<div className="alert alert-info" style={aiSetupStyle}>
<Icons.spark style={{ width: 16, height: 16, flexShrink: 0, marginTop: 2 }} />
<div>
<div style={{ fontWeight: 600, marginBottom: 6 }}>
{t("sites.ai_image.title", "AI Image Generation")}
</div>
<div className="caption" style={{ color: "var(--text-muted)" }}>
{mode === "edit" && site?.id
? t(
"sites.ai_image.edit_body",
"Image generation is configured per service in Tool access. Add an OpenAI, Stability AI, Replicate, or OpenRouter key there; OpenRouter can also use a default image model.",
)
: t(
"sites.ai_image.create_body",
"After creating this service, open Tool access to add an OpenAI, Stability AI, Replicate, or OpenRouter key. The image generation tool stays unavailable until a provider key is saved and the service connection is healthy.",
)}
</div>
{mode === "edit" && site?.id ? (
<a
className="btn btn-secondary btn-sm"
href={`/dashboard/sites/${site.id}/tools`}
style={{ marginTop: 10, alignSelf: "flex-start" }}
>
{t("sites.ai_image.open_tools", "Open AI Image Generation settings")}
</a>
) : null}
</div>
</div>
) : null}
<div className="field">
<label>{t("sites.field_alias", "Alias")}</label>
<input
className="input"
value={alias}
onChange={(e) => setAlias(e.target.value)}
placeholder={t("sites.alias_placeholder", "short-id-for-this-site")}
disabled={submitting}
/>
<div className="caption" style={{ marginTop: 4 }}>
{t(
"sites.alias_hint",
"Short identifier the AI sees as `site=…`. Stick to lowercase letters, digits, and dashes.",
)}
</div>
</div>
<div className="field">
<label>{t("sites.field_url", "URL")}</label>
<input
className="input"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com"
disabled={submitting}
/>
</div>
{/* Credential fields per plugin */}
{fields.length > 0 ? (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div className="eyebrow">{t("sites.credentials", "Credentials")}</div>
{fields
.filter((f) => !f.advanced || showAdvanced)
.map((field) => (
<div key={field.name} className="field">
<label>
{field.label}
{field.required ? " *" : ""}
</label>
<input
className="input"
type={field.type === "password" ? "password" : "text"}
value={creds[field.name] ?? ""}
onChange={(e) =>
setCreds((prev) => ({ ...prev, [field.name]: e.target.value }))
}
placeholder={
mode === "edit" && !field.required
? t("sites.cred_unchanged", "Leave blank to keep current")
: ""
}
disabled={submitting}
/>
{field.hint ? (
<div className="caption" style={{ marginTop: 4 }}>
{field.hint}
</div>
) : null}
</div>
))}
{advancedFields.length > 0 ? (
<Btn
variant="ghost"
size="sm"
onClick={() => setShowAdvanced((v) => !v)}
type="button"
style={{ alignSelf: lang === "fa" ? "flex-end" : "flex-start" }}
>
{advancedFieldsLabel(showAdvanced, currentPlugin?.name, fields, t)}
</Btn>
) : null}
</div>
) : null}
{error ? (
<div className="alert alert-danger">
<Icons.warning style={{ width: 16, height: 16, flexShrink: 0 }} />
<div style={{ flex: 1, fontSize: 13 }}>{error}</div>
</div>
) : null}
</div>
<div style={footerStyle}>
<Btn variant="ghost" onClick={onCancel} disabled={submitting}>
{t("cancel", "Cancel")}
</Btn>
<Btn
variant="primary"
onClick={onSubmit}
disabled={
submitting ||
plugins.isLoading ||
baseFieldsMissing ||
requiredFieldsMissing
}
>
{submitting
? "…"
: mode === "create"
? t("sites.dialog_add_submit", "Add site")
: t("sites.dialog_edit_submit", "Save changes")}
</Btn>
</div>
</Card>
</div>
</div>
);
}
const backdropStyle: CSSProperties = {
position: "fixed",
inset: 0,
background: "oklch(0.10 0.012 250 / 0.55)",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
zIndex: 60,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 20,
overflowY: "auto",
};
const dialogStyle: CSSProperties = {
width: "min(720px, 100%)",
maxHeight: "calc(100dvh - 40px)",
display: "flex",
flexDirection: "column",
};
const headerStyle: CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "18px 22px",
borderBottom: "1px solid var(--border)",
};
const bodyStyle: CSSProperties = {
padding: "20px 22px",
display: "flex",
flexDirection: "column",
gap: 16,
overflowY: "auto",
};
const guidanceStyle: CSSProperties = {
alignItems: "flex-start",
fontSize: 12,
lineHeight: 1.5,
};
const guidanceListStyle: CSSProperties = {
margin: 0,
paddingInlineStart: 18,
display: "flex",
flexDirection: "column",
gap: 4,
};
const aiSetupStyle: CSSProperties = {
alignItems: "flex-start",
fontSize: 12,
lineHeight: 1.5,
};
const footerStyle: CSSProperties = {
display: "flex",
gap: 10,
justifyContent: "flex-end",
padding: "16px 22px",
borderTop: "1px solid var(--border)",
};

View File

@@ -0,0 +1,143 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Icons } from "./icons";
import { isMobileViewport, useUiStore, type ThemePref } from "../lib/store";
import { useT } from "../lib/i18n";
const THEME_ORDER: ThemePref[] = ["light", "dark", "system"];
export function Topbar({
title,
crumbs = [],
actions,
showThemeToggle = true,
}: {
title?: string;
crumbs?: string[];
actions?: ReactNode;
showThemeToggle?: boolean;
}) {
const t = useT();
const [langOpen, setLangOpen] = useState(false);
const langMenuRef = useRef<HTMLDivElement | null>(null);
const { theme, setTheme, lang, setLang, sidebarCollapsed, toggleSidebar, sidebarMobileOpen, toggleSidebarMobile } =
useUiStore();
const ThemeIcon = theme === "dark" ? Icons.moon : theme === "light" ? Icons.sun : Icons.monitor;
const cycleTheme = () => {
const idx = THEME_ORDER.indexOf(theme);
setTheme(THEME_ORDER[(idx + 1) % THEME_ORDER.length]);
};
// Same button serves two layouts: on phones it toggles the slide-in
// drawer, on desktops it collapses the sidebar to an icon rail.
const onMenuClick = () => {
if (isMobileViewport()) toggleSidebarMobile();
else toggleSidebar();
};
const menuExpanded = isMobileViewport() ? sidebarMobileOpen : !sidebarCollapsed;
useEffect(() => {
if (!langOpen) return;
const onPointerDown = (event: PointerEvent) => {
if (!langMenuRef.current?.contains(event.target as Node)) {
setLangOpen(false);
}
};
window.addEventListener("pointerdown", onPointerDown);
return () => window.removeEventListener("pointerdown", onPointerDown);
}, [langOpen]);
const chooseLang = (next: "en" | "fa") => {
setLang(next);
setLangOpen(false);
};
return (
<div className="topbar">
<button
type="button"
className="btn btn-ghost btn-sm topbar-menu-btn"
onClick={onMenuClick}
title={t("topbar.toggle_sidebar", "Toggle sidebar")}
aria-label={t("topbar.toggle_sidebar", "Toggle sidebar")}
aria-expanded={menuExpanded}
style={{ padding: 6 }}
>
<Icons.menu style={{ width: 18, height: 18 }} />
</button>
<div className="topbar-crumbs">
{crumbs.map((c, i) => (
<span key={i} style={{ display: "inline-flex", alignItems: "center", gap: 10 }}>
{i > 0 ? <Icons.chevR style={{ width: 12, height: 12, color: "var(--text-subtle)" }} /> : null}
<span style={{ color: i === crumbs.length - 1 ? "var(--text)" : "var(--text-muted)" }}>{c}</span>
</span>
))}
{!crumbs.length && title ? <span style={{ fontWeight: 500 }}>{title}</span> : null}
</div>
<div style={{ flex: 1 }} />
{actions ? <div className="topbar-actions">{actions}</div> : null}
{showThemeToggle ? (
<div className="topbar-controls">
<div className="lang-menu-wrap" ref={langMenuRef}>
<button
type="button"
className="btn btn-ghost btn-sm topbar-lang-btn"
onClick={() => setLangOpen((open) => !open)}
title={t("topbar.change_language", "Change language")}
aria-label={t("topbar.change_language", "Change language")}
aria-haspopup="menu"
aria-expanded={langOpen}
style={{ padding: "6px 10px", display: "inline-flex", alignItems: "center", gap: 6 }}
>
<Icons.globe style={{ width: 14, height: 14 }} />
<span style={{ fontSize: 11.5, fontWeight: 600, letterSpacing: "0.06em" }}>
{lang === "fa" ? "FA" : "EN"}
</span>
</button>
{langOpen ? (
<div className="lang-menu" role="menu" aria-label={t("topbar.change_language", "Change language")}>
<button
type="button"
role="menuitemradio"
aria-checked={lang === "en"}
className={lang === "en" ? "is-active" : ""}
onClick={() => chooseLang("en")}
>
<span>English</span>
{lang === "en" ? <Icons.check style={{ width: 14, height: 14 }} /> : null}
</button>
<button
type="button"
role="menuitemradio"
aria-checked={lang === "fa"}
className={lang === "fa" ? "is-active" : ""}
onClick={() => chooseLang("fa")}
>
<span>فارسی</span>
{lang === "fa" ? <Icons.check style={{ width: 14, height: 14 }} /> : null}
</button>
</div>
) : null}
</div>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={cycleTheme}
title={`${t("topbar.cycle_theme", "Switch theme")} · ${t(`theme.${theme}`, theme)}`}
aria-label={t("topbar.cycle_theme", "Switch theme")}
style={{ padding: 6 }}
>
<ThemeIcon style={{ width: 16, height: 16 }} />
</button>
<button
type="button"
className="btn btn-ghost btn-sm topbar-notifs"
style={{ padding: 6 }}
title={t("topbar.notifications", "Notifications")}
aria-label={t("topbar.notifications", "Notifications")}
>
<Icons.bell style={{ width: 16, height: 16 }} />
</button>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,301 @@
// Lucide-style inline SVG icons.
// Stroke 1.75, 24px viewBox. Color via currentColor so they inherit from text.
import type { SVGProps, ReactNode, FC } from "react";
type Props = SVGProps<SVGSVGElement>;
const make =
(path: ReactNode, viewBox = "0 0 24 24"): FC<Props> =>
(props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={viewBox}
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
width={16}
height={16}
{...props}
>
{path}
</svg>
);
export const Icons = {
home: make(
<>
<path d="M3 12 12 3l9 9" />
<path d="M5 10v10h14V10" />
</>,
),
sites: make(
<>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M3 9h18" />
<path d="M8 4v5" />
</>,
),
plug: make(
<>
<path d="M9 2v6" />
<path d="M15 2v6" />
<path d="M6 8h12v4a6 6 0 0 1-12 0V8Z" />
<path d="M12 18v4" />
</>,
),
key: make(
<>
<circle cx="8" cy="15" r="4" />
<path d="m10.5 12.5 9-9" />
<path d="m18 5 3 3" />
<path d="m15 8 3 3" />
</>,
),
shield: make(<path d="M12 3 4 6v6c0 5 3.5 8.5 8 9 4.5-.5 8-4 8-9V6l-8-3Z" />),
activity: make(<path d="M22 12h-4l-3 9L9 3l-3 9H2" />),
logs: make(
<>
<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9Z" />
<path d="M14 3v6h6" />
<path d="M8 13h8" />
<path d="M8 17h6" />
</>,
),
settings: make(
<>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h0A1.7 1.7 0 0 0 10 3.1V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v0a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z" />
</>,
),
search: make(
<>
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</>,
),
user: make(
<>
<circle cx="12" cy="8" r="4" />
<path d="M4 21a8 8 0 0 1 16 0" />
</>,
),
bell: make(
<>
<path d="M6 8a6 6 0 1 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a2 2 0 0 0 3.4 0" />
</>,
),
chev: make(<path d="m6 9 6 6 6-6" />),
chevR: make(<path d="m9 6 6 6-6 6" />),
check: make(<path d="M20 6 9 17l-5-5" />),
plus: make(
<>
<path d="M12 5v14" />
<path d="M5 12h14" />
</>,
),
arrow: make(
<>
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</>,
),
copy: make(
<>
<rect x="8" y="8" width="13" height="13" rx="2" />
<path d="M5 16V5a2 2 0 0 1 2-2h11" />
</>,
),
eye: make(
<>
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" />
<circle cx="12" cy="12" r="3" />
</>,
),
eyeOff: make(
<>
<path d="M10.7 5.1A8 8 0 0 1 12 5c6.5 0 10 7 10 7a16 16 0 0 1-3.3 4" />
<path d="M6.1 6.1A16 16 0 0 0 2 12s3.5 7 10 7a10 10 0 0 0 4.5-1" />
<path d="m2 2 20 20" />
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
</>,
),
trash: make(
<>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
</>,
),
edit: make(
<>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="m18.5 2.5 3 3L12 15l-4 1 1-4 9.5-9.5Z" />
</>,
),
download: make(
<>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path d="m7 10 5 5 5-5" />
<path d="M12 15V3" />
</>,
),
link: make(
<>
<path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1" />
<path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1" />
</>,
),
github: make(
<>
<path d="M15 22v-4a3.4 3.4 0 0 0-1-2.8c3.3-.3 6.6-1.6 6.6-7A5.5 5.5 0 0 0 19 4.8 5 5 0 0 0 18.9 1s-1.2-.3-4 1.5a13.4 13.4 0 0 0-7 0C5.1.7 3.9 1 3.9 1A5 5 0 0 0 3.8 4.8 5.5 5.5 0 0 0 2.4 8.2c0 5.4 3.3 6.7 6.5 7A3.4 3.4 0 0 0 8 18v4" />
<path d="M9 18c-4 1.5-4.5-2-6.5-2" />
</>,
),
terminal: make(
<>
<path d="m4 17 6-6-6-6" />
<path d="M12 19h8" />
</>,
),
chart: make(
<>
<path d="M3 3v18h18" />
<path d="m7 15 4-4 4 4 6-6" />
</>,
),
spark: make(
<path d="M12 3v3M12 18v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M3 12h3M18 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1" />,
),
zap: make(<path d="M13 2 3 14h7l-1 8 10-12h-7l1-8Z" />),
globe: make(
<>
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18" />
<path d="M12 3a14 14 0 0 1 0 18" />
<path d="M12 3a14 14 0 0 0 0 18" />
</>,
),
users: make(
<>
<circle cx="9" cy="8" r="4" />
<path d="M2 21a7 7 0 0 1 14 0" />
<path d="M16 3.1a4 4 0 0 1 0 7.8" />
<path d="M22 21a7 7 0 0 0-6-6.9" />
</>,
),
logout: make(
<>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
</>,
),
moon: make(<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" />),
monitor: make(
<>
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M8 21h8M12 17v4" />
</>,
),
sun: make(
<>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</>,
),
menu: make(
<>
<path d="M4 6h16" />
<path d="M4 12h16" />
<path d="M4 18h16" />
</>,
),
x: make(
<>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</>,
),
sparkles: make(
<>
<path d="M12 3v4M12 17v4M5 12H1M23 12h-4M7 7 4 4M20 20l-3-3M7 17l-3 3M20 4l-3 3" />
<circle cx="12" cy="12" r="3" />
</>,
),
info: make(
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 8v.01" />
<path d="M11 12h1v4h1" />
</>,
),
warning: make(
<>
<path d="m12 3 10 17H2L12 3z" />
<path d="M12 9v5" />
<path d="M12 18v.01" />
</>,
),
server: make(
<>
<rect x="3" y="4" width="18" height="7" rx="2" />
<rect x="3" y="13" width="18" height="7" rx="2" />
<path d="M7 8h.01M7 17h.01" />
</>,
),
filter: make(<path d="M4 4h16l-6 8v7l-4-2v-5L4 4z" />),
refresh: make(
<>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-15 6.7L3 16" />
<path d="M3 21v-5h5" />
</>,
),
clock: make(
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</>,
),
cube: make(
<>
<path d="M21 8 12 3 3 8l9 5 9-5Z" />
<path d="M3 8v8l9 5 9-5V8" />
<path d="M12 13v8" />
</>,
),
rocket: make(
<>
<path d="M5 13 3 19l6-2" />
<path d="M14 6s6-5 8-2-2 8-2 8l-9 9-6-6 9-9Z" />
<circle cx="15" cy="9" r="1.5" />
</>,
),
wrench: make(
<path d="M15 5a4 4 0 0 1-4.5 4L4 15.5 8.5 20 15 13.5a4 4 0 0 1 5-5l-3-3 3-3a4 4 0 0 0-5 2.5Z" />,
),
bolt: make(<path d="M13 3 3 14h7l-1 7 10-11h-7l1-7Z" />),
docs: make(
<>
<path d="M4 4v16a2 2 0 0 0 2 2h12V2H6a2 2 0 0 0-2 2Z" />
<path d="M18 2v20" />
<path d="M8 7h6M8 11h6M8 15h4" />
</>,
),
command: make(
<path d="M6 2a4 4 0 1 1-4 4V2h4Zm12 0a4 4 0 1 0 4 4V2h-4ZM6 22a4 4 0 1 0-4-4v4h4Zm12 0a4 4 0 1 1 4-4v4h-4ZM6 6h12v12H6V6Z" />,
),
external: make(
<>
<path d="M14 3h7v7" />
<path d="M10 14 21 3" />
<path d="M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5" />
</>,
),
} as const;
export type IconName = keyof typeof Icons;

View File

@@ -0,0 +1,365 @@
// Shared UI primitives — typed ports of prototype/src/primitives.jsx.
import { useEffect, useState } from "react";
import type { ReactNode, ButtonHTMLAttributes, HTMLAttributes, FC } from "react";
import { Icons, type IconName } from "../icons";
// ---------- Badge ----------
type BadgeVariant = "default" | "success" | "warning" | "danger" | "info" | "brand";
type BadgeProps = HTMLAttributes<HTMLSpanElement> & {
variant?: BadgeVariant;
dot?: boolean;
};
export function Badge({ children, variant = "default", dot = false, className = "", ...rest }: BadgeProps) {
const cls =
"badge " +
(variant !== "default" ? `badge-${variant} ` : "") +
(dot ? "badge-dot " : "") +
className;
return (
<span className={cls} {...rest}>
{children}
</span>
);
}
// ---------- Button ----------
type BtnVariant = "primary" | "secondary" | "ghost" | "danger";
type BtnProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: BtnVariant;
size?: "" | "sm" | "lg";
icon?: IconName | FC<any>;
iconRight?: IconName | FC<any>;
children?: ReactNode;
};
export function Btn({
children,
variant = "primary",
size = "",
icon,
iconRight,
className,
...rest
}: BtnProps) {
const Ic = typeof icon === "string" ? Icons[icon] : icon;
const IcR = typeof iconRight === "string" ? Icons[iconRight] : iconRight;
const cls = `btn btn-${variant}${size ? " btn-" + size : ""}${className ? " " + className : ""}`;
return (
<button type="button" className={cls} {...rest}>
{Ic ? <Ic style={{ width: 14, height: 14 }} /> : null}
{children}
{IcR ? <IcR style={{ width: 14, height: 14 }} /> : null}
</button>
);
}
// ---------- Card ----------
export function Card({ children, className = "", ...rest }: HTMLAttributes<HTMLDivElement>) {
return (
<div className={`card ${className}`} {...rest}>
{children}
</div>
);
}
export function CardHead({
title,
subtitle,
action,
icon,
}: {
title: ReactNode;
subtitle?: ReactNode;
action?: ReactNode;
icon?: IconName | FC<any>;
}) {
const Ic = typeof icon === "string" ? Icons[icon] : icon;
return (
<div className="card-head">
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
{Ic ? (
<div
style={{
width: 34,
height: 34,
borderRadius: 8,
background: "var(--surface)",
border: "1px solid var(--border)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--brand-400)",
}}
>
<Ic style={{ width: 16, height: 16 }} />
</div>
) : null}
<div>
<div style={{ fontSize: 14, fontWeight: 600 }}>{title}</div>
{subtitle ? <div className="caption" style={{ marginTop: 2 }}>{subtitle}</div> : null}
</div>
</div>
{action}
</div>
);
}
// ---------- Switch ----------
export function Switch({ on, onChange }: { on: boolean; onChange: (v: boolean) => void }) {
return (
<div
role="switch"
aria-checked={on}
tabIndex={0}
onClick={() => onChange(!on)}
onKeyDown={(e) => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
onChange(!on);
}
}}
className={`switch ${on ? "on" : ""}`}
/>
);
}
// ---------- Segmented ----------
export type SegOption<T extends string> = { value: T; label: string };
export function Seg<T extends string>({
value,
onChange,
options,
}: {
value: T;
onChange: (v: T) => void;
options: SegOption<T>[];
}) {
return (
<div className="seg">
{options.map((o) => (
<button
key={o.value}
type="button"
className={o.value === value ? "is-active" : ""}
onClick={() => onChange(o.value)}
>
{o.label}
</button>
))}
</div>
);
}
// ---------- CopyField ----------
export function CopyField({ value, mask = false }: { value: string; mask?: boolean }) {
const [copied, setCopied] = useState(false);
const [show, setShow] = useState(!mask);
const display = mask && !show ? "•".repeat(Math.min(40, value.length)) : value;
const onCopy = async () => {
try {
await navigator.clipboard.writeText(value);
} catch {
// ignore — clipboard may be blocked
}
setCopied(true);
setTimeout(() => setCopied(false), 1400);
};
return (
<div className="copy-field">
<span>{display}</span>
{mask ? (
<button type="button" onClick={() => setShow((s) => !s)} title={show ? "Hide" : "Show"}>
{show ? (
<Icons.eyeOff style={{ width: 14, height: 14 }} />
) : (
<Icons.eye style={{ width: 14, height: 14 }} />
)}
</button>
) : null}
<button type="button" onClick={onCopy}>
{copied ? "Copied" : "Copy"}
</button>
</div>
);
}
// ---------- Spark ----------
export function Spark({ values = [], height = 20 }: { values?: number[]; height?: number }) {
const max = Math.max(...values, 1);
return (
<div className="spark" style={{ height }}>
{values.map((v, i) => (
<span
key={i}
style={{
height: `${Math.max(10, (v / max) * 100)}%`,
opacity: 0.4 + (i / Math.max(1, values.length)) * 0.6,
}}
/>
))}
</div>
);
}
// ---------- Donut ----------
export function Donut({ value = 40, size = 72, color }: { value?: number; size?: number; color?: string }) {
const r = size / 2 - 6;
const c = 2 * Math.PI * r;
const stroke = color || "var(--brand-500)";
return (
<svg width={size} height={size} className="donut">
<circle cx={size / 2} cy={size / 2} r={r} stroke="var(--surface-2)" />
<circle
cx={size / 2}
cy={size / 2}
r={r}
stroke={stroke}
strokeDasharray={c}
strokeDashoffset={c - (value / 100) * c}
strokeLinecap="round"
/>
</svg>
);
}
// ---------- Avatar ----------
export function Avatar({ name = "U", size = 28 }: { name?: string; size?: number }) {
const initial = name.trim().slice(0, 1).toUpperCase();
const hash = [...name].reduce((a, ch) => a + ch.charCodeAt(0), 0);
const hue = (hash * 37) % 360;
return (
<div
style={{
width: size,
height: size,
borderRadius: "50%",
background: `oklch(0.55 0.14 ${hue})`,
color: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: size * 0.42,
fontWeight: 600,
flexShrink: 0,
}}
>
{initial}
</div>
);
}
// ---------- EmptyState ----------
export function EmptyState({
icon = "cube",
title,
children,
action,
}: {
icon?: IconName;
title: ReactNode;
children?: ReactNode;
action?: ReactNode;
}) {
const Ic = Icons[icon];
return (
<div style={{ padding: "48px 20px", textAlign: "center" }}>
<div
style={{
width: 52,
height: 52,
margin: "0 auto 16px",
borderRadius: 12,
background: "var(--surface)",
border: "1px solid var(--border)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--text-subtle)",
}}
>
<Ic style={{ width: 22, height: 22 }} />
</div>
<div className="h-3" style={{ marginBottom: 6 }}>
{title}
</div>
<div className="caption" style={{ maxWidth: 380, margin: "0 auto 16px" }}>
{children}
</div>
{action}
</div>
);
}
// ---------- Toast ----------
export function Toast({ msg, onClose }: { msg: string; onClose: () => void }) {
useEffect(() => {
if (!msg) return;
const t = setTimeout(onClose, 2800);
return () => clearTimeout(t);
}, [msg, onClose]);
if (!msg) return null;
return (
<div
style={{
position: "fixed",
bottom: 24,
left: "50%",
transform: "translateX(-50%)",
background: "var(--bg-elevated)",
border: "1px solid var(--border)",
borderRadius: 10,
padding: "10px 16px",
boxShadow: "var(--shadow-pop)",
fontSize: 13,
zIndex: 200,
display: "flex",
gap: 10,
alignItems: "center",
}}
>
<Icons.check style={{ width: 14, height: 14, color: "var(--success)" }} />
{msg}
</div>
);
}
// ---------- Step ----------
export function Step({
n,
title,
children,
ghost = false,
done = false,
}: {
n: number;
title: ReactNode;
children?: ReactNode;
ghost?: boolean;
done?: boolean;
}) {
return (
<div style={{ display: "flex", gap: 14 }}>
<div
style={{
width: 28,
height: 28,
borderRadius: "50%",
flexShrink: 0,
background: done ? "var(--success)" : ghost ? "var(--surface)" : "var(--brand-500)",
color: done ? "#000" : ghost ? "var(--text-muted)" : "#000",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 600,
fontSize: 12,
border: ghost ? "1px solid var(--border)" : "none",
}}
>
{done ? <Icons.check style={{ width: 12, height: 12 }} /> : n}
</div>
<div style={{ flex: 1, paddingTop: 2 }}>
<div style={{ fontSize: 14, fontWeight: 500, marginBottom: 10 }}>{title}</div>
{children}
</div>
</div>
);
}