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:
65
web/src/components/Logo.tsx
Normal file
65
web/src/components/Logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
web/src/components/PublicControls.tsx
Normal file
89
web/src/components/PublicControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
web/src/components/Shell.tsx
Normal file
54
web/src/components/Shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
165
web/src/components/Sidebar.tsx
Normal file
165
web/src/components/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
487
web/src/components/SiteFormDialog.tsx
Normal file
487
web/src/components/SiteFormDialog.tsx
Normal 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)",
|
||||
};
|
||||
143
web/src/components/Topbar.tsx
Normal file
143
web/src/components/Topbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
301
web/src/components/icons.tsx
Normal file
301
web/src/components/icons.tsx
Normal 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;
|
||||
365
web/src/components/primitives/index.tsx
Normal file
365
web/src/components/primitives/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user