feat(ui): implement split-pane live preview for design systems
This commit is contained in:
@@ -1,89 +1,37 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Design kit explorer — persisted starter kit + user overrides per project.
|
||||
* See lib/design-kits/* and /api/projects/[id]/design-kit.
|
||||
*/
|
||||
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
ChevronRight,
|
||||
LayoutGrid,
|
||||
Loader2,
|
||||
Palette,
|
||||
RotateCcw,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import { Loader2, Check, Search, Eye, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { DesignKitOverrides } from "@/lib/design-kits/types";
|
||||
import {
|
||||
DEFAULT_DESIGN_KIT_ID,
|
||||
UI_FOUNDATION_LABELS,
|
||||
} from "@/lib/design-kits/types";
|
||||
import { STARTER_KITS, getStarterKit } from "@/lib/design-kits/registry";
|
||||
import { mergeOverrides, resolveKitTokens } from "@/lib/design-kits/resolve";
|
||||
import {
|
||||
DESIGN_KIT_SECTIONS,
|
||||
renderKitPanel,
|
||||
} from "@/components/project/design-kit-panels";
|
||||
import { STARTER_KITS } from "@/lib/design-kits/registry";
|
||||
import { DEFAULT_DESIGN_KIT_ID } from "@/lib/design-kits/types";
|
||||
|
||||
export function DesignSystemExplorer() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
|
||||
const [kitId, setKitId] = useState<string>(DEFAULT_DESIGN_KIT_ID);
|
||||
const [perKit, setPerKit] = useState<Record<string, DesignKitOverrides>>({});
|
||||
const [draft, setDraft] = useState<DesignKitOverrides>({});
|
||||
const [activeKitId, setActiveKitId] = useState<string>(DEFAULT_DESIGN_KIT_ID);
|
||||
const [previewKitId, setPreviewKitId] = useState<string>(DEFAULT_DESIGN_KIT_ID);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [openIds, setOpenIds] = useState<Set<string>>(
|
||||
() => new Set(["c-accent"]),
|
||||
);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const kit = useMemo(
|
||||
() =>
|
||||
getStarterKit(kitId) ??
|
||||
getStarterKit(DEFAULT_DESIGN_KIT_ID) ??
|
||||
STARTER_KITS[0],
|
||||
[kitId],
|
||||
);
|
||||
const savedForKit = perKit[kitId] ?? {};
|
||||
const mergedDraft = useMemo(
|
||||
() => ({ ...savedForKit, ...draft }),
|
||||
[savedForKit, draft],
|
||||
);
|
||||
const tokens = useMemo(
|
||||
() => resolveKitTokens(kit, mergedDraft),
|
||||
[kit, mergedDraft],
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetch(`/api/projects/${projectId}/design-kit`, { credentials: "include" })
|
||||
.then((r) => {
|
||||
if (!r.ok)
|
||||
throw new Error(
|
||||
r.status === 401
|
||||
? "Sign in to load design kit"
|
||||
: `HTTP ${r.status}`,
|
||||
);
|
||||
if (!r.ok) throw new Error(r.status === 401 ? "Sign in to load design kit" : `HTTP ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.then(
|
||||
(d: {
|
||||
kitId?: string;
|
||||
perKit?: Record<string, DesignKitOverrides>;
|
||||
}) => {
|
||||
if (cancelled) return;
|
||||
if (d.kitId && getStarterKit(d.kitId)) setKitId(d.kitId);
|
||||
setPerKit(d.perKit && typeof d.perKit === "object" ? d.perKit : {});
|
||||
setDraft({});
|
||||
},
|
||||
)
|
||||
.then((d: { kitId?: string }) => {
|
||||
if (cancelled) return;
|
||||
if (d.kitId) {
|
||||
setActiveKitId(d.kitId);
|
||||
setPreviewKitId(d.kitId);
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (!cancelled) toast.error(e.message || "Failed to load design kit");
|
||||
})
|
||||
@@ -95,694 +43,156 @@ export function DesignSystemExplorer() {
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
const persistKitId = useCallback(
|
||||
async (nextId: string) => {
|
||||
const res = await fetch(`/api/projects/${projectId}/design-kit`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ kitId: nextId }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Could not save kit");
|
||||
const data = await res.json();
|
||||
setKitId(data.kitId);
|
||||
setPerKit(data.perKit ?? {});
|
||||
setDraft({});
|
||||
toast.success("Starter kit saved", {
|
||||
description:
|
||||
"Ask Vibn in chat to apply this theme to your codebase (globals.css / Tailwind / theme provider). Large apps may need a token refactor first.",
|
||||
});
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const persistOverrides = useCallback(async () => {
|
||||
const selectKit = useCallback(async (kitId: string) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/design-kit`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ overrides: draft }),
|
||||
body: JSON.stringify({ kitId }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Could not save customization");
|
||||
if (!res.ok) throw new Error("Could not save kit");
|
||||
const data = await res.json();
|
||||
setPerKit(data.perKit ?? {});
|
||||
setDraft({});
|
||||
toast.success("Theme saved", {
|
||||
description:
|
||||
"Saved to this project for the AI. Ask Vibn in chat to wire these tokens into your app. If colors are scattered across files, it may need a short refactor.",
|
||||
setActiveKitId(data.kitId || kitId);
|
||||
toast.success("Design System Updated", {
|
||||
description: "The AI agent now has full context of this design system's guidelines and tokens. Ask it to apply the theme in chat!",
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Save failed");
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || "Failed to switch design system");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [projectId, draft]);
|
||||
}, [projectId]);
|
||||
|
||||
const resetToKitDefaults = useCallback(() => {
|
||||
setDraft({});
|
||||
void (async () => {
|
||||
try {
|
||||
const defaults = kit.defaults;
|
||||
const res = await fetch(`/api/projects/${projectId}/design-kit`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
overrides: {
|
||||
accentHex: defaults.accentHex,
|
||||
radiusMdPx: defaults.radiusMdPx,
|
||||
fontPreset: defaults.fontPreset,
|
||||
density: defaults.density,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error("Reset failed");
|
||||
const data = await res.json();
|
||||
setPerKit(data.perKit ?? {});
|
||||
toast.message("Reset to starter defaults");
|
||||
} catch {
|
||||
toast.error("Reset failed");
|
||||
}
|
||||
})();
|
||||
}, [kit.defaults, projectId]);
|
||||
|
||||
const toggle = useCallback((id: string) => {
|
||||
setOpenIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const effective = mergeOverrides(kit, mergedDraft);
|
||||
const filteredKits = useMemo(() => {
|
||||
const s = search.toLowerCase();
|
||||
return STARTER_KITS.filter(k => k.name.toLowerCase().includes(s) || k.tagline.toLowerCase().includes(s));
|
||||
}, [search]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...page,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Loader2
|
||||
className="animate-spin"
|
||||
style={{ width: 22, height: 22, color: "#9c9590" }}
|
||||
/>
|
||||
<span style={{ fontSize: "0.85rem", color: "#71717a" }}>
|
||||
Loading design kit…
|
||||
</span>
|
||||
<div className="flex-1 bg-[#fbfaf9] p-6 md:p-10 flex items-center justify-center">
|
||||
<div className="flex items-center gap-3 text-zinc-500">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span className="text-sm">Loading design system...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const previewKit = STARTER_KITS.find(k => k.id === previewKitId) || STARTER_KITS[0];
|
||||
const isActive = activeKitId === previewKitId;
|
||||
|
||||
return (
|
||||
<div style={page}>
|
||||
<header style={header}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div style={headerIcon}>
|
||||
<LayoutGrid size={18} strokeWidth={2} />
|
||||
</div>
|
||||
<div>
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: "1.05rem",
|
||||
fontWeight: 600,
|
||||
color: "#18181b",
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
{kit.name}
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
margin: "4px 0 0",
|
||||
fontSize: "0.78rem",
|
||||
color: "#71717a",
|
||||
}}
|
||||
>
|
||||
{kit.tagline}
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
marginTop: 4,
|
||||
fontSize: "0.72rem",
|
||||
color: "#a1a1aa",
|
||||
}}
|
||||
>
|
||||
{UI_FOUNDATION_LABELS[kit.uiFoundation]}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<label
|
||||
style={{
|
||||
fontSize: "0.72rem",
|
||||
color: "#71717a",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Palette size={14} />
|
||||
Starter kit
|
||||
</label>
|
||||
<select
|
||||
value={kitId}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
void persistKitId(next).catch(() =>
|
||||
toast.error("Could not switch kit"),
|
||||
);
|
||||
}}
|
||||
style={selectStyle}
|
||||
>
|
||||
{STARTER_KITS.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{k.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{kitId === "flyonui" ? (
|
||||
<section
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
borderRadius: 12,
|
||||
overflow: "hidden",
|
||||
border: "1px solid #e4e4e7",
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
borderBottom: "1px solid #e4e4e7",
|
||||
display: "flex",
|
||||
gap: 12,
|
||||
alignItems: "center",
|
||||
background: "#fafafa",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{ fontSize: "0.85rem", fontWeight: 500, color: "#18181b" }}
|
||||
>
|
||||
FlyonUI Themes:
|
||||
</span>
|
||||
{[
|
||||
"light",
|
||||
"dark",
|
||||
"cupcake",
|
||||
"retro",
|
||||
"cyberpunk",
|
||||
"synthwave",
|
||||
"valentine",
|
||||
"aqua",
|
||||
].map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{ type: "SET_THEME", theme: t },
|
||||
"*",
|
||||
)
|
||||
}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #e4e4e7",
|
||||
background: "#fff",
|
||||
fontSize: "0.75rem",
|
||||
cursor: "pointer",
|
||||
color: "#18181b",
|
||||
}}
|
||||
>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src="/preview/daisyui"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 800,
|
||||
border: "none",
|
||||
display: "block",
|
||||
}}
|
||||
title="Theme Sandbox"
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<aside style={customizeCard}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.72rem",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
color: "#71717a",
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
CUSTOMIZE THIS KIT
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{kit.customizeFields.includes("accent") ? (
|
||||
<label style={fieldLabel}>
|
||||
Accent color
|
||||
<input
|
||||
type="color"
|
||||
value={effective.accentHex}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, accentHex: e.target.value }))
|
||||
}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 40,
|
||||
padding: 4,
|
||||
borderRadius: 8,
|
||||
border: "1px solid #e4e4e7",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={
|
||||
draft.accentHex ??
|
||||
savedForKit.accentHex ??
|
||||
kit.defaults.accentHex ??
|
||||
""
|
||||
}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, accentHex: e.target.value }))
|
||||
}
|
||||
style={textInput}
|
||||
placeholder="#5f6bf5"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{kit.customizeFields.includes("radius") ? (
|
||||
<label style={fieldLabel}>
|
||||
Radius (md) · {effective.radiusMdPx}px
|
||||
<input
|
||||
type="range"
|
||||
min={4}
|
||||
max={16}
|
||||
value={effective.radiusMdPx}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
radiusMdPx: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
style={{ width: "100%", marginTop: 8 }}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{kit.customizeFields.includes("font") ? (
|
||||
<label style={fieldLabel}>
|
||||
Font
|
||||
<select
|
||||
value={effective.fontPreset}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
fontPreset: e.target
|
||||
.value as DesignKitOverrides["fontPreset"],
|
||||
}))
|
||||
}
|
||||
style={selectStyleFull}
|
||||
>
|
||||
<option value="inter">Inter (app default)</option>
|
||||
<option value="system">System UI</option>
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
{kit.customizeFields.includes("density") ? (
|
||||
<label style={fieldLabel}>
|
||||
Density
|
||||
<select
|
||||
value={effective.density}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
density: e.target.value as DesignKitOverrides["density"],
|
||||
}))
|
||||
}
|
||||
style={selectStyleFull}
|
||||
>
|
||||
<option value="comfortable">Comfortable</option>
|
||||
<option value="compact">Compact</option>
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
style={{ display: "flex", flexWrap: "wrap", gap: 10, marginTop: 18 }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || Object.keys(draft).length === 0}
|
||||
onClick={() => void persistOverrides()}
|
||||
style={btnPrimary}
|
||||
>
|
||||
<Save size={14} />
|
||||
Save customization
|
||||
</button>
|
||||
<button type="button" onClick={resetToKitDefaults} style={btnGhost}>
|
||||
<RotateCcw size={14} />
|
||||
Reset to starter defaults
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.72rem",
|
||||
color: "#a1a1aa",
|
||||
marginTop: 14,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Each starter kit keeps its own saved overrides. Switch kits anytime —
|
||||
nothing is lost. Next step: export these as CSS variables for codegen
|
||||
/ dev container.
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 28 }}>
|
||||
{kit.id === "flyonui" ? (
|
||||
<section>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 600,
|
||||
color: "#18181b",
|
||||
}}
|
||||
>
|
||||
FlyonUI Themes:
|
||||
</span>
|
||||
{[
|
||||
"light",
|
||||
"dark",
|
||||
"cupcake",
|
||||
"retro",
|
||||
"cyberpunk",
|
||||
"synthwave",
|
||||
"valentine",
|
||||
"aqua",
|
||||
].map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{ type: "SET_THEME", theme: t },
|
||||
"*",
|
||||
)
|
||||
}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #e4e4e7",
|
||||
background: "#fff",
|
||||
fontSize: "0.75rem",
|
||||
cursor: "pointer",
|
||||
color: "#18181b",
|
||||
}}
|
||||
>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src="/preview/daisyui"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 800,
|
||||
border: "1px solid #e4e4e7",
|
||||
borderRadius: 12,
|
||||
background: "#fff",
|
||||
}}
|
||||
<div className="flex-1 bg-[#fbfaf9] h-full flex flex-col md:flex-row overflow-hidden">
|
||||
|
||||
{/* LEFT SIDEBAR: Library & Search */}
|
||||
<div className="w-full md:w-80 flex-shrink-0 border-r border-zinc-200 bg-white flex flex-col h-full overflow-hidden">
|
||||
<div className="p-4 border-b border-zinc-200">
|
||||
<h2 className="text-sm font-semibold text-zinc-900 mb-3 flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-indigo-600" />
|
||||
Vibn Design Library
|
||||
</h2>
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search 150+ systems..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 bg-zinc-50 border border-zinc-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all"
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{DESIGN_KIT_SECTIONS.map((section) => (
|
||||
<section key={section.title}>
|
||||
<h2 style={sectionTitle}>{section.title}</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{section.items.map((item) => (
|
||||
<AccordionRow
|
||||
key={item.id}
|
||||
open={openIds.has(item.id)}
|
||||
onToggle={() => toggle(item.id)}
|
||||
title={item.title}
|
||||
subtitle={item.subtitle}
|
||||
>
|
||||
{renderKitPanel(item.panelKey, tokens)}
|
||||
</AccordionRow>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionRow({
|
||||
open,
|
||||
onToggle,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div style={accordionOuter}>
|
||||
<button type="button" onClick={onToggle} style={accordionTrigger}>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
style={{
|
||||
color: "#8c8580",
|
||||
flexShrink: 0,
|
||||
transform: open ? "rotate(90deg)" : "none",
|
||||
transition: "transform 0.15s ease",
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0, textAlign: "left" }}>
|
||||
<div
|
||||
style={{ fontSize: "0.875rem", fontWeight: 600, color: "#18181b" }}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{subtitle && open ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.72rem",
|
||||
color: "#71717a",
|
||||
marginTop: 4,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span
|
||||
title="Live preview from your tokens"
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
background: "#3d5afe",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{open ? <div style={accordionBody}>{children}</div> : null}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1 scrollbar-hide">
|
||||
{filteredKits.map((kit) => {
|
||||
const isSelected = previewKitId === kit.id;
|
||||
const isCurrentlyActive = activeKitId === kit.id;
|
||||
return (
|
||||
<button
|
||||
key={kit.id}
|
||||
onClick={() => setPreviewKitId(kit.id)}
|
||||
className={`w-full text-left p-3 rounded-lg flex items-start gap-3 transition-colors ${
|
||||
isSelected ? 'bg-indigo-50/80 ring-1 ring-indigo-500/30' : 'hover:bg-zinc-50'
|
||||
}`}
|
||||
>
|
||||
<div className={`mt-0.5 flex-shrink-0 w-4 h-4 rounded-full border flex items-center justify-center ${
|
||||
isCurrentlyActive
|
||||
? 'border-indigo-600 bg-indigo-600'
|
||||
: isSelected
|
||||
? 'border-indigo-300'
|
||||
: 'border-zinc-300'
|
||||
}`}>
|
||||
{isCurrentlyActive && <Check className="w-2.5 h-2.5 text-white" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-medium truncate ${isSelected ? 'text-indigo-950' : 'text-zinc-900'}`}>
|
||||
{kit.name}
|
||||
</div>
|
||||
<div className={`text-[11px] truncate mt-0.5 ${isSelected ? 'text-indigo-700/70' : 'text-zinc-500'}`}>
|
||||
{kit.tagline || kit.id}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filteredKits.length === 0 && (
|
||||
<div className="p-4 text-center text-zinc-500 text-xs">
|
||||
No design systems found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT MAIN AREA: Live Preview & Action Bar */}
|
||||
<div className="flex-1 flex flex-col h-full bg-zinc-100 overflow-hidden relative">
|
||||
{/* Top Action Bar */}
|
||||
<div className="h-14 flex-shrink-0 bg-white border-b border-zinc-200 px-6 flex items-center justify-between shadow-sm z-10">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Eye className="w-4 h-4 text-zinc-400" />
|
||||
<span className="text-sm font-medium text-zinc-900 truncate">
|
||||
Previewing: {previewKit.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => selectKit(previewKit.id)}
|
||||
disabled={saving || isActive}
|
||||
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-2 ${
|
||||
isActive
|
||||
? 'bg-zinc-100 text-zinc-400 cursor-default'
|
||||
: 'bg-indigo-600 hover:bg-indigo-700 text-white shadow-sm'
|
||||
}`}
|
||||
>
|
||||
{saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
{isActive ? 'Active System' : 'Set as Active Theme'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Live Iframe */}
|
||||
<div className="flex-1 relative bg-white m-4 rounded-xl border border-zinc-200 shadow-sm overflow-hidden flex flex-col">
|
||||
<div className="h-8 bg-zinc-50 border-b border-zinc-200 flex items-center px-4 gap-2 flex-shrink-0">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-red-400/80"></div>
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-amber-400/80"></div>
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-green-400/80"></div>
|
||||
</div>
|
||||
<div className="mx-auto bg-white px-3 py-0.5 rounded-md border border-zinc-200 text-[10px] text-zinc-400 font-mono">
|
||||
{previewKit.id}.design.preview
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
key={previewKit.id} // Forces iframe to remount and show loading state if needed
|
||||
src={`/api/design-systems/${previewKit.id}/preview`}
|
||||
className="flex-1 w-full h-full bg-white"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const page: CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
width: "100%",
|
||||
overflow: "auto",
|
||||
boxSizing: "border-box",
|
||||
padding: "28px 36px 48px",
|
||||
background: "#fafafa",
|
||||
fontFamily: "var(--font-inter), ui-sans-serif, system-ui, sans-serif",
|
||||
};
|
||||
|
||||
const header: CSSProperties = {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
gap: 16,
|
||||
marginBottom: 22,
|
||||
paddingBottom: 20,
|
||||
borderBottom: "1px solid #e4e4e7",
|
||||
};
|
||||
|
||||
const headerIcon: CSSProperties = {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
background: "#fff",
|
||||
border: "1px solid #e4e4e7",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#52525b",
|
||||
};
|
||||
|
||||
const selectStyle: CSSProperties = {
|
||||
padding: "8px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid #e4e4e7",
|
||||
fontSize: "0.82rem",
|
||||
background: "#fff",
|
||||
color: "#18181b",
|
||||
minWidth: 220,
|
||||
};
|
||||
|
||||
const customizeCard: CSSProperties = {
|
||||
marginBottom: 28,
|
||||
padding: 20,
|
||||
borderRadius: 12,
|
||||
border: "1px solid #e4e4e7",
|
||||
background: "#fff",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.04)",
|
||||
};
|
||||
|
||||
const fieldLabel: CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 6,
|
||||
fontSize: "0.72rem",
|
||||
fontWeight: 600,
|
||||
color: "#52525b",
|
||||
};
|
||||
|
||||
const textInput: CSSProperties = {
|
||||
padding: "8px 10px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid #e4e4e7",
|
||||
fontSize: "0.82rem",
|
||||
fontFamily: "var(--font-ibm-plex-mono), monospace",
|
||||
};
|
||||
|
||||
const selectStyleFull: CSSProperties = {
|
||||
padding: "8px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid #e4e4e7",
|
||||
fontSize: "0.82rem",
|
||||
background: "#fff",
|
||||
color: "#18181b",
|
||||
minWidth: "100%",
|
||||
marginTop: 4,
|
||||
};
|
||||
|
||||
const btnPrimary: CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "10px 16px",
|
||||
borderRadius: 10,
|
||||
border: "none",
|
||||
background: "#18181b",
|
||||
color: "#fff",
|
||||
fontSize: "0.82rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const btnGhost: CSSProperties = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "10px 16px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid #e4e4e7",
|
||||
background: "#fafafa",
|
||||
color: "#52525b",
|
||||
fontSize: "0.82rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const sectionTitle: CSSProperties = {
|
||||
margin: "0 0 12px",
|
||||
fontSize: "0.72rem",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
color: "#a1a1aa",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
|
||||
const accordionOuter: CSSProperties = {
|
||||
borderRadius: 10,
|
||||
border: "1px solid #e4e4e7",
|
||||
background: "#fff",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const accordionTrigger: CSSProperties = {
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 10,
|
||||
padding: "14px 16px",
|
||||
border: "none",
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
const accordionBody: CSSProperties = {
|
||||
padding: "16px 16px 18px 44px",
|
||||
borderTop: "1px solid #f4f4f5",
|
||||
background: "#fafafa",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user