Merge pull request #20 from offendingcommit/feat/peer-card-seed-kits
feat(web): add peer card seed kits
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
ChevronsUpDown,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Lightbulb,
|
||||
MessageSquare,
|
||||
@@ -30,6 +31,7 @@ import { COLOR } from "@/lib/constants";
|
||||
const TOP_NAV = [
|
||||
{ to: "/" as const, label: "Dashboard", icon: LayoutDashboard, exact: true },
|
||||
{ to: "/workspaces" as const, label: "Workspaces", icon: Boxes, exact: false },
|
||||
{ to: "/seed-kits" as const, label: "Seed Kits", icon: Layers, exact: false },
|
||||
{ to: "/settings" as const, label: "Settings", icon: Settings, exact: false },
|
||||
];
|
||||
|
||||
|
||||
311
packages/web/src/components/seed-kits/ApplyKitDialog.tsx
Normal file
311
packages/web/src/components/seed-kits/ApplyKitDialog.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Check, Loader2, Sparkles, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useScopedPeerCard, useScopedPeers } from "@/api/compareQueries";
|
||||
import { createScopedClient } from "@/api/scopedClient";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Body, Caption, Muted } from "@/components/ui/typography";
|
||||
import { useInstances } from "@/hooks/useInstances";
|
||||
import type { Instance } from "@/lib/config";
|
||||
import { mergeCardLines, type SeedKit } from "@/lib/seedKits";
|
||||
|
||||
interface ApplyKitDialogProps {
|
||||
kit: SeedKit | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function err(e: unknown): never {
|
||||
throw new Error(typeof e === "object" ? JSON.stringify(e) : String(e));
|
||||
}
|
||||
|
||||
function useScopedWorkspacesAll(instance: Instance | null) {
|
||||
return useQuery({
|
||||
queryKey: ["seed-kits-workspaces", instance?.id ?? "none"],
|
||||
queryFn: async () => {
|
||||
if (!instance) return [] as Array<{ id: string }>;
|
||||
const client = createScopedClient(instance);
|
||||
const { data, error } = await client.POST("/v3/workspaces/list", {
|
||||
params: { query: { page: 1, page_size: 100 } },
|
||||
body: {},
|
||||
});
|
||||
const payload = data ?? err(error);
|
||||
return ((payload as { items?: Array<{ id: string }> }).items ?? []) as Array<{ id: string }>;
|
||||
},
|
||||
enabled: Boolean(instance),
|
||||
});
|
||||
}
|
||||
|
||||
export function ApplyKitDialog({ kit, open, onClose }: ApplyKitDialogProps) {
|
||||
const { instances, activeId } = useInstances();
|
||||
const [instanceId, setInstanceId] = useState<string | null>(activeId);
|
||||
const [workspaceId, setWorkspaceId] = useState<string | null>(null);
|
||||
const [peerId, setPeerId] = useState<string | null>(null);
|
||||
const [submitState, setSubmitState] = useState<
|
||||
{ kind: "idle" } | { kind: "pending" } | { kind: "ok" } | { kind: "error"; message: string }
|
||||
>({ kind: "idle" });
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setInstanceId(activeId);
|
||||
setWorkspaceId(null);
|
||||
setPeerId(null);
|
||||
setSubmitState({ kind: "idle" });
|
||||
}
|
||||
}, [open, activeId]);
|
||||
|
||||
const instance = instances.find((i) => i.id === instanceId) ?? null;
|
||||
|
||||
const workspaces = useScopedWorkspacesAll(instance);
|
||||
const peers = useScopedPeers(instance as Instance, workspaceId ?? "", 1, 100);
|
||||
const existingCard = useScopedPeerCard(instance as Instance, workspaceId ?? "", peerId ?? "");
|
||||
|
||||
const peerItems = (peers.data as { items?: Array<{ id: string }> } | undefined)?.items ?? [];
|
||||
|
||||
const existingLines = useMemo(() => {
|
||||
const card = existingCard.data as { peer_card?: unknown } | undefined;
|
||||
if (Array.isArray(card?.peer_card)) return card.peer_card as string[];
|
||||
if (typeof card === "string") return [card];
|
||||
return [] as string[];
|
||||
}, [existingCard.data]);
|
||||
|
||||
const mergedLines = useMemo(
|
||||
() => (kit ? mergeCardLines(existingLines, kit.lines) : existingLines),
|
||||
[kit, existingLines],
|
||||
);
|
||||
|
||||
const canApply =
|
||||
kit !== null &&
|
||||
instance !== null &&
|
||||
Boolean(workspaceId) &&
|
||||
Boolean(peerId) &&
|
||||
submitState.kind !== "pending";
|
||||
|
||||
async function handleApply() {
|
||||
if (!kit || !instance || !workspaceId || !peerId) return;
|
||||
setSubmitState({ kind: "pending" });
|
||||
try {
|
||||
const client = createScopedClient(instance);
|
||||
const { error } = await client.PUT("/v3/workspaces/{workspace_id}/peers/{peer_id}/card", {
|
||||
params: { path: { workspace_id: workspaceId, peer_id: peerId } },
|
||||
body: { peer_card: mergedLines },
|
||||
});
|
||||
if (error) {
|
||||
setSubmitState({
|
||||
kind: "error",
|
||||
message: typeof error === "object" ? JSON.stringify(error) : String(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSubmitState({ kind: "ok" });
|
||||
await existingCard.refetch();
|
||||
setTimeout(onClose, 700);
|
||||
} catch (e) {
|
||||
setSubmitState({
|
||||
kind: "error",
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles
|
||||
className="w-4 h-4"
|
||||
style={{ color: "var(--accent-text)" }}
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
Apply seed kit
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{kit ? (
|
||||
<>
|
||||
Apply <span className="font-medium">{kit.name}</span> to a peer's card. Existing
|
||||
lines with a matching prefix are replaced; everything else is appended.
|
||||
</>
|
||||
) : (
|
||||
"Pick a kit to apply."
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<PickerRow label="Instance">
|
||||
<select
|
||||
value={instanceId ?? ""}
|
||||
onChange={(e) => {
|
||||
setInstanceId(e.target.value || null);
|
||||
setWorkspaceId(null);
|
||||
setPeerId(null);
|
||||
}}
|
||||
className="flex-1 rounded-lg px-3 py-2 text-sm outline-none"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
color: "var(--text-1)",
|
||||
border: "1px solid var(--border-2)",
|
||||
}}
|
||||
>
|
||||
<option value="">— pick an instance —</option>
|
||||
{instances.map((i) => (
|
||||
<option key={i.id} value={i.id}>
|
||||
{i.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</PickerRow>
|
||||
|
||||
<PickerRow label="Workspace">
|
||||
<select
|
||||
value={workspaceId ?? ""}
|
||||
onChange={(e) => {
|
||||
setWorkspaceId(e.target.value || null);
|
||||
setPeerId(null);
|
||||
}}
|
||||
disabled={!instance || workspaces.isLoading}
|
||||
className="flex-1 rounded-lg px-3 py-2 text-sm outline-none disabled:opacity-50"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
color: "var(--text-1)",
|
||||
border: "1px solid var(--border-2)",
|
||||
}}
|
||||
>
|
||||
<option value="">{workspaces.isLoading ? "loading…" : "— pick a workspace —"}</option>
|
||||
{(workspaces.data ?? []).map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</PickerRow>
|
||||
|
||||
<PickerRow label="Peer">
|
||||
<select
|
||||
value={peerId ?? ""}
|
||||
onChange={(e) => setPeerId(e.target.value || null)}
|
||||
disabled={!workspaceId || peers.isLoading}
|
||||
className="flex-1 rounded-lg px-3 py-2 text-sm outline-none disabled:opacity-50"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
color: "var(--text-1)",
|
||||
border: "1px solid var(--border-2)",
|
||||
}}
|
||||
>
|
||||
<option value="">{peers.isLoading ? "loading…" : "— pick a peer —"}</option>
|
||||
{peerItems.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</PickerRow>
|
||||
</div>
|
||||
|
||||
{kit && peerId && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<MergePreview
|
||||
existing={existingLines}
|
||||
merged={mergedLines}
|
||||
loading={existingCard.isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitState.kind === "error" && (
|
||||
<p className="text-xs mt-3" style={{ color: "#f87171" }}>
|
||||
{submitState.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 mt-4">
|
||||
<Button type="button" variant="surface" onClick={onClose}>
|
||||
<X className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" variant="accent" onClick={handleApply} disabled={!canApply}>
|
||||
{submitState.kind === "pending" ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" strokeWidth={2} />
|
||||
) : submitState.kind === "ok" ? (
|
||||
<Check className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
) : (
|
||||
<Sparkles className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
)}
|
||||
{submitState.kind === "ok" ? "Applied" : "Apply kit"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PickerRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/noLabelWithoutControl: select element is provided via `children`
|
||||
<label className="flex items-center gap-3">
|
||||
<span className="text-xs font-medium w-20 shrink-0" style={{ color: "var(--text-3)" }}>
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
interface MergePreviewProps {
|
||||
existing: string[];
|
||||
merged: string[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function MergePreview({ existing, merged, loading }: MergePreviewProps) {
|
||||
const existingSet = useMemo(() => new Set(existing), [existing]);
|
||||
|
||||
if (loading) {
|
||||
return <Muted className="text-xs">Loading existing card…</Muted>;
|
||||
}
|
||||
|
||||
if (merged.length === 0) {
|
||||
return <Muted className="text-xs">Nothing to apply.</Muted>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Caption className="block mb-1.5">Preview after apply</Caption>
|
||||
<div
|
||||
className="rounded-lg p-3 font-mono text-xs space-y-0.5 max-h-64 overflow-y-auto"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border-2)",
|
||||
}}
|
||||
>
|
||||
{merged.map((line, i) => {
|
||||
const isNew = !existingSet.has(line);
|
||||
return (
|
||||
<div
|
||||
key={`${i}-${line}`}
|
||||
style={{
|
||||
color: isNew ? "var(--accent-text)" : "var(--text-2)",
|
||||
fontWeight: isNew ? 500 : 400,
|
||||
}}
|
||||
>
|
||||
{isNew ? "+ " : " "}
|
||||
{line || <span style={{ color: "var(--text-4)" }}>(empty)</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Body className="text-xs mt-1.5" style={{ color: "var(--text-3)" }}>
|
||||
<span style={{ color: "var(--accent-text)" }}>+ </span>
|
||||
new or replaced • {merged.length} total line{merged.length === 1 ? "" : "s"}
|
||||
</Body>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
packages/web/src/components/seed-kits/SeedKitForm.tsx
Normal file
121
packages/web/src/components/seed-kits/SeedKitForm.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Save, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input, Textarea } from "@/components/ui/input";
|
||||
import type { SeedKit } from "@/lib/seedKits";
|
||||
|
||||
interface SeedKitFormProps {
|
||||
initial?: Pick<SeedKit, "name" | "description" | "lines">;
|
||||
onSubmit: (input: { name: string; description: string; lines: string[] }) => void;
|
||||
onCancel: () => void;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
export function SeedKitForm({
|
||||
initial,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitLabel = "Save kit",
|
||||
}: SeedKitFormProps) {
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [description, setDescription] = useState(initial?.description ?? "");
|
||||
const [linesText, setLinesText] = useState((initial?.lines ?? []).join("\n"));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
setError("Name is required");
|
||||
return;
|
||||
}
|
||||
const lines = linesText
|
||||
.split("\n")
|
||||
.map((l) => l.trimEnd())
|
||||
.filter((l) => l.length > 0);
|
||||
if (lines.length === 0) {
|
||||
setError("Add at least one line");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
onSubmit({ name: trimmedName, description: description.trim(), lines });
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="kit-name"
|
||||
className="text-xs font-medium block mb-1"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="kit-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Personal core"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="kit-description"
|
||||
className="text-xs font-medium block mb-1"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
Description{" "}
|
||||
<span style={{ color: "var(--text-4)" }} className="font-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<Input
|
||||
id="kit-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Short summary of what this kit seeds"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="kit-lines"
|
||||
className="text-xs font-medium block mb-1"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
Lines{" "}
|
||||
<span style={{ color: "var(--text-4)" }} className="font-normal">
|
||||
(one per line — e.g. <span className="font-mono">Name: Ben</span>)
|
||||
</span>
|
||||
</label>
|
||||
<Textarea
|
||||
id="kit-lines"
|
||||
value={linesText}
|
||||
onChange={(e) => setLinesText(e.target.value)}
|
||||
rows={10}
|
||||
className="font-mono resize-y"
|
||||
style={{ minHeight: "12rem" }}
|
||||
placeholder={"Name: \nEmail: \nRole: "}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs" style={{ color: "#f87171" }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="surface" onClick={onCancel}>
|
||||
<X className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="accent">
|
||||
<Save className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
365
packages/web/src/components/seed-kits/SeedKitsView.tsx
Normal file
365
packages/web/src/components/seed-kits/SeedKitsView.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Copy, Layers, Pencil, Plus, Sparkles, Trash2 } from "lucide-react";
|
||||
import { useState, useSyncExternalStore } from "react";
|
||||
import { ApplyKitDialog } from "@/components/seed-kits/ApplyKitDialog";
|
||||
import { SeedKitForm } from "@/components/seed-kits/SeedKitForm";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Body, Caption, MonoCaption, PageTitle } from "@/components/ui/typography";
|
||||
import {
|
||||
BUILTIN_KITS,
|
||||
createKit,
|
||||
deleteKit,
|
||||
isBuiltinKit,
|
||||
loadUserKits,
|
||||
type SeedKit,
|
||||
updateKit,
|
||||
} from "@/lib/seedKits";
|
||||
|
||||
const EVENT = "openconcho:seed-kits-changed";
|
||||
|
||||
function emit() {
|
||||
window.dispatchEvent(new Event(EVENT));
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void): () => void {
|
||||
window.addEventListener(EVENT, cb);
|
||||
window.addEventListener("storage", cb);
|
||||
return () => {
|
||||
window.removeEventListener(EVENT, cb);
|
||||
window.removeEventListener("storage", cb);
|
||||
};
|
||||
}
|
||||
|
||||
let cachedKey = "";
|
||||
let cachedSnapshot: SeedKit[] = [];
|
||||
|
||||
function getSnapshot(): SeedKit[] {
|
||||
const next = loadUserKits();
|
||||
const key = JSON.stringify(next);
|
||||
if (key !== cachedKey) {
|
||||
cachedKey = key;
|
||||
cachedSnapshot = next;
|
||||
}
|
||||
return cachedSnapshot;
|
||||
}
|
||||
|
||||
function getServerSnapshot(): SeedKit[] {
|
||||
return cachedSnapshot;
|
||||
}
|
||||
|
||||
function useUserKits(): SeedKit[] {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
|
||||
type Mode =
|
||||
| { kind: "list" }
|
||||
| { kind: "create"; initial?: Pick<SeedKit, "name" | "description" | "lines"> }
|
||||
| { kind: "edit"; id: string };
|
||||
|
||||
export function SeedKitsView() {
|
||||
const userKits = useUserKits();
|
||||
const [mode, setMode] = useState<Mode>({ kind: "list" });
|
||||
const [applyTarget, setApplyTarget] = useState<SeedKit | null>(null);
|
||||
|
||||
if (mode.kind === "create") {
|
||||
return (
|
||||
<div className="page-container">
|
||||
<header className="mb-6">
|
||||
<PageTitle>New seed kit</PageTitle>
|
||||
<Body className="mt-1">Define the lines that will be merged into a peer's card.</Body>
|
||||
</header>
|
||||
<div
|
||||
className="rounded-xl p-5"
|
||||
style={{
|
||||
background: "var(--bg-2)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<SeedKitForm
|
||||
initial={mode.initial}
|
||||
submitLabel="Create kit"
|
||||
onSubmit={(input) => {
|
||||
createKit(input);
|
||||
emit();
|
||||
setMode({ kind: "list" });
|
||||
}}
|
||||
onCancel={() => setMode({ kind: "list" })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode.kind === "edit") {
|
||||
const kit = userKits.find((k) => k.id === mode.id);
|
||||
if (!kit) {
|
||||
return (
|
||||
<div className="page-container">
|
||||
<Body>Kit not found.</Body>
|
||||
<Button variant="ghost" onClick={() => setMode({ kind: "list" })} className="mt-3">
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="page-container">
|
||||
<header className="mb-6">
|
||||
<PageTitle>Edit seed kit</PageTitle>
|
||||
<Body className="mt-1">{kit.name}</Body>
|
||||
</header>
|
||||
<div
|
||||
className="rounded-xl p-5"
|
||||
style={{
|
||||
background: "var(--bg-2)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<SeedKitForm
|
||||
initial={kit}
|
||||
submitLabel="Save changes"
|
||||
onSubmit={(input) => {
|
||||
updateKit(kit.id, input);
|
||||
emit();
|
||||
setMode({ kind: "list" });
|
||||
}}
|
||||
onCancel={() => setMode({ kind: "list" })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<motion.header
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-6 flex items-start justify-between gap-4 flex-wrap"
|
||||
>
|
||||
<div>
|
||||
<PageTitle className="flex items-center gap-2">
|
||||
<Layers className="w-5 h-5" style={{ color: "var(--accent-text)" }} strokeWidth={1.5} />
|
||||
Seed Kits
|
||||
</PageTitle>
|
||||
<Body className="mt-1 max-w-xl">
|
||||
Pre-defined sets of card lines you can apply to any peer in one click. Useful for
|
||||
seeding identity facts across multiple agents.
|
||||
</Body>
|
||||
</div>
|
||||
<Button variant="accent" onClick={() => setMode({ kind: "create" })}>
|
||||
<Plus className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
New kit
|
||||
</Button>
|
||||
</motion.header>
|
||||
|
||||
<section className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h2
|
||||
className="text-xs font-medium uppercase tracking-wider"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
Built-in starters
|
||||
</h2>
|
||||
<Caption>Read-only — fork to customize</Caption>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{BUILTIN_KITS.map((kit) => (
|
||||
<KitCard
|
||||
key={kit.id}
|
||||
kit={kit}
|
||||
onApply={() => setApplyTarget(kit)}
|
||||
onFork={() =>
|
||||
setMode({
|
||||
kind: "create",
|
||||
initial: {
|
||||
name: `${kit.name} (copy)`,
|
||||
description: kit.description,
|
||||
lines: [...kit.lines],
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h2
|
||||
className="text-xs font-medium uppercase tracking-wider"
|
||||
style={{ color: "var(--text-3)" }}
|
||||
>
|
||||
Your kits
|
||||
</h2>
|
||||
<Caption>
|
||||
{userKits.length} kit{userKits.length === 1 ? "" : "s"}
|
||||
</Caption>
|
||||
</div>
|
||||
{userKits.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Sparkles}
|
||||
title="No custom kits yet"
|
||||
description="Create your first kit, or fork a built-in to get started."
|
||||
action={
|
||||
<Button variant="accent" onClick={() => setMode({ kind: "create" })}>
|
||||
<Plus className="w-3.5 h-3.5" strokeWidth={2} />
|
||||
New kit
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{userKits.map((kit) => (
|
||||
<KitCard
|
||||
key={kit.id}
|
||||
kit={kit}
|
||||
onApply={() => setApplyTarget(kit)}
|
||||
onEdit={() => setMode({ kind: "edit", id: kit.id })}
|
||||
onDelete={() => {
|
||||
deleteKit(kit.id);
|
||||
emit();
|
||||
}}
|
||||
onFork={() =>
|
||||
setMode({
|
||||
kind: "create",
|
||||
initial: {
|
||||
name: `${kit.name} (copy)`,
|
||||
description: kit.description,
|
||||
lines: [...kit.lines],
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<ApplyKitDialog
|
||||
kit={applyTarget}
|
||||
open={applyTarget !== null}
|
||||
onClose={() => setApplyTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface KitCardProps {
|
||||
kit: SeedKit;
|
||||
onApply: () => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
onFork: () => void;
|
||||
}
|
||||
|
||||
function KitCard({ kit, onApply, onEdit, onDelete, onFork }: KitCardProps) {
|
||||
const builtin = isBuiltinKit(kit);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="rounded-xl p-4 flex flex-col gap-3"
|
||||
style={{
|
||||
background: "var(--bg-2)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<h3
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ color: "var(--text-1)" }}
|
||||
title={kit.name}
|
||||
>
|
||||
{kit.name}
|
||||
</h3>
|
||||
{builtin && (
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-medium"
|
||||
style={{
|
||||
background: "var(--accent-dim)",
|
||||
color: "var(--accent-text)",
|
||||
border: "1px solid var(--accent-border)",
|
||||
}}
|
||||
>
|
||||
built-in
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{kit.description && <Caption className="block leading-relaxed">{kit.description}</Caption>}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded-md px-3 py-2 font-mono text-xs space-y-0.5 max-h-32 overflow-y-auto"
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{kit.lines.length === 0 ? (
|
||||
<MonoCaption>(no lines)</MonoCaption>
|
||||
) : (
|
||||
kit.lines.map((line, i) => (
|
||||
<div
|
||||
key={`${i}-${line}`}
|
||||
className="truncate"
|
||||
style={{ color: "var(--text-2)" }}
|
||||
title={line}
|
||||
>
|
||||
{line || <span style={{ color: "var(--text-4)" }}>(empty)</span>}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 mt-auto">
|
||||
<Button variant="accent" size="sm" onClick={onApply}>
|
||||
<Sparkles className="w-3 h-3" strokeWidth={2} />
|
||||
Apply
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={onFork} title="Fork into a new kit">
|
||||
<Copy className="w-3 h-3" strokeWidth={2} />
|
||||
</Button>
|
||||
{!builtin && onEdit && (
|
||||
<Button variant="ghost" size="sm" onClick={onEdit} title="Edit">
|
||||
<Pencil className="w-3 h-3" strokeWidth={2} />
|
||||
</Button>
|
||||
)}
|
||||
{!builtin &&
|
||||
onDelete &&
|
||||
(confirmingDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
setConfirmingDelete(false);
|
||||
}}
|
||||
className="text-xs font-medium px-2 py-1 rounded-md"
|
||||
style={{
|
||||
color: "#f87171",
|
||||
border: "1px solid #f87171",
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" strokeWidth={2} />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user