@vvedantb/ui 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,211 @@
1
+ import type { MouseEvent, ReactNode } from "react";
2
+ import {
3
+ IconCopy,
4
+ IconDots,
5
+ IconDownload,
6
+ IconExternalLink,
7
+ IconLayoutDashboard,
8
+ IconPencil,
9
+ IconTrash,
10
+ } from "@tabler/icons-react";
11
+ import { toast } from "sonner";
12
+ import { ConfirmSkipHint, skipConfirmTitle } from "../../app/src/confirm/ConfirmSkipHint";
13
+ import { formatCompactRelativeTime } from "../../app/src/time/compactRelativeTime";
14
+ import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "../ui/context-menu";
15
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
16
+ import { cn } from "../utils/cn";
17
+ import { useArtifactEditDialogs } from "./artifact-dialogs";
18
+ import { copyArtifactBody, downloadArtifactBody, openArtifactInNewTab } from "./artifact-file";
19
+ import { ArtifactTypeIcon } from "./artifact-type-icon";
20
+ import type { ArtifactSummary } from "./types";
21
+
22
+ export type ArtifactLayout = "grid" | "list";
23
+
24
+ /** Callbacks shared by `ArtifactCard` and `ArtifactList`. Each menu action shows only when its callback is set. */
25
+ export type ArtifactCardActions = {
26
+ /** Open the artifact. With `getHref`, a plain left click calls this instead of following the link. */
27
+ onOpen?: (artifact: ArtifactSummary) => void;
28
+ /** Link target: the card becomes an `<a>` and the menu gains "Open in new tab". */
29
+ getHref?: (artifact: ArtifactSummary) => string;
30
+ /** Loads the body for Copy and Download (list rows carry no body). */
31
+ getBody?: (artifact: ArtifactSummary) => Promise<string>;
32
+ /** Called with the trimmed new title from the rename dialog. */
33
+ onRename?: (artifact: ArtifactSummary, title: string) => void | Promise<void>;
34
+ /** Called after the delete dialog, or straight away on an Alt-click. */
35
+ onDelete?: (artifact: ArtifactSummary) => void | Promise<void>;
36
+ };
37
+
38
+ export type ArtifactCardProps = ArtifactCardActions & {
39
+ artifact: ArtifactSummary;
40
+ /** `"grid"` (default): a tile with the description. `"list"`: one compact row. */
41
+ layout?: ArtifactLayout;
42
+ className?: string;
43
+ };
44
+
45
+ type MenuAction = {
46
+ label: string;
47
+ icon: ReactNode;
48
+ onSelect: (event: MouseEvent) => void;
49
+ destructive?: boolean;
50
+ };
51
+
52
+ function isPlainClick(event: MouseEvent) {
53
+ return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
54
+ }
55
+
56
+ const menuTriggerClass =
57
+ "flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-surface-tertiary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring data-[state=open]:bg-surface-tertiary";
58
+
59
+ /**
60
+ * One artifact as a grid tile or a list row, with an actions menu (also on
61
+ * right-click) and the rename and delete dialogs. Data comes in through props
62
+ * and leaves through callbacks; the card makes no requests of its own.
63
+ */
64
+ export function ArtifactCard({
65
+ artifact,
66
+ layout = "grid",
67
+ onOpen,
68
+ getHref,
69
+ getBody,
70
+ onRename,
71
+ onDelete,
72
+ className,
73
+ }: ArtifactCardProps) {
74
+ const edit = useArtifactEditDialogs({
75
+ title: artifact.title,
76
+ onRename: onRename && ((title) => onRename(artifact, title)),
77
+ onDelete: onDelete && (() => onDelete(artifact)),
78
+ });
79
+ const href = getHref?.(artifact);
80
+
81
+ const withBody = (use: (body: string) => void | Promise<void>) => {
82
+ if (!getBody) return;
83
+ void getBody(artifact).then(use, () => toast.error("Couldn't load artifact"));
84
+ };
85
+
86
+ const actions: MenuAction[] = [];
87
+ if (onOpen) actions.push({ label: "Open", icon: <IconLayoutDashboard size={16} />, onSelect: () => onOpen(artifact) });
88
+ if (href !== undefined) {
89
+ actions.push({ label: "Open in new tab", icon: <IconExternalLink size={16} />, onSelect: () => openArtifactInNewTab(href) });
90
+ }
91
+ if (onRename) actions.push({ label: "Rename", icon: <IconPencil size={16} />, onSelect: edit.startRename });
92
+ if (getBody) {
93
+ actions.push({ label: "Copy", icon: <IconCopy size={16} />, onSelect: () => withBody(copyArtifactBody) });
94
+ actions.push({
95
+ label: "Download",
96
+ icon: <IconDownload size={16} />,
97
+ onSelect: () => withBody((body) => downloadArtifactBody(artifact.title, artifact.format, body)),
98
+ });
99
+ }
100
+ if (onDelete) {
101
+ actions.push({ label: "Delete", icon: <IconTrash size={16} />, onSelect: edit.requestDelete, destructive: true });
102
+ }
103
+
104
+ const itemProps = (action: MenuAction) => ({
105
+ className: action.destructive ? "text-danger" : undefined,
106
+ startContent: action.icon,
107
+ title: action.destructive ? skipConfirmTitle(action.label) : undefined,
108
+ endContent: action.destructive ? <ConfirmSkipHint /> : undefined,
109
+ onClick: action.onSelect,
110
+ });
111
+
112
+ const list = layout === "list";
113
+ const openClass = cn(
114
+ "transition-[background-color,transform] duration-200 ease-smooth active:scale-[0.99] active:duration-100",
115
+ list
116
+ ? "flex min-w-0 flex-1 items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-secondary"
117
+ : "flex h-full w-full flex-col gap-2 rounded-surface bg-surface-secondary p-4 text-left hover:bg-surface-tertiary",
118
+ );
119
+ const updated = <span className="shrink-0 text-xs text-muted-foreground">{formatCompactRelativeTime(artifact.updatedAt)}</span>;
120
+ const content = list ? (
121
+ <>
122
+ <ArtifactTypeIcon type={artifact.type} />
123
+ <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">{artifact.title}</span>
124
+ {updated}
125
+ </>
126
+ ) : (
127
+ <>
128
+ <span className="flex min-w-0 items-center gap-2">
129
+ <ArtifactTypeIcon type={artifact.type} size={18} />
130
+ <span className="truncate text-sm font-medium text-foreground">{artifact.title}</span>
131
+ </span>
132
+ {artifact.description ? (
133
+ <span className="line-clamp-2 text-sm text-muted-foreground">{artifact.description}</span>
134
+ ) : null}
135
+ <span className="mt-auto pr-8">{updated}</span>
136
+ </>
137
+ );
138
+
139
+ const openTarget =
140
+ href !== undefined ? (
141
+ <a
142
+ href={href}
143
+ className={openClass}
144
+ onClick={(event) => {
145
+ if (!onOpen || !isPlainClick(event)) return;
146
+ event.preventDefault();
147
+ onOpen(artifact);
148
+ }}
149
+ >
150
+ {content}
151
+ </a>
152
+ ) : onOpen ? (
153
+ <button type="button" className={openClass} onClick={() => onOpen(artifact)}>
154
+ {content}
155
+ </button>
156
+ ) : (
157
+ <div className={openClass}>{content}</div>
158
+ );
159
+
160
+ const menu =
161
+ actions.length > 0 ? (
162
+ <DropdownMenu>
163
+ <DropdownMenuTrigger asChild>
164
+ <button
165
+ type="button"
166
+ aria-label="Artifact actions"
167
+ className={cn(menuTriggerClass, list ? "shrink-0" : "absolute bottom-3 right-2")}
168
+ >
169
+ <IconDots className="size-3.5" />
170
+ </button>
171
+ </DropdownMenuTrigger>
172
+ <DropdownMenuContent fitViewport suppressAltKey restoreScrollLockOnClose align="end">
173
+ {actions.map((action) => (
174
+ <DropdownMenuItem key={action.label} {...itemProps(action)}>
175
+ {action.label}
176
+ </DropdownMenuItem>
177
+ ))}
178
+ </DropdownMenuContent>
179
+ </DropdownMenu>
180
+ ) : null;
181
+
182
+ const card = (
183
+ <div
184
+ data-artifact-id={artifact.id}
185
+ className={cn("relative", list ? "flex items-center gap-2" : "h-full", className)}
186
+ >
187
+ {openTarget}
188
+ {menu}
189
+ </div>
190
+ );
191
+
192
+ return (
193
+ <>
194
+ {actions.length > 0 ? (
195
+ <ContextMenu>
196
+ <ContextMenuTrigger asChild>{card}</ContextMenuTrigger>
197
+ <ContextMenuContent suppressAltKey>
198
+ {actions.map((action) => (
199
+ <ContextMenuItem key={action.label} {...itemProps(action)}>
200
+ {action.label}
201
+ </ContextMenuItem>
202
+ ))}
203
+ </ContextMenuContent>
204
+ </ContextMenu>
205
+ ) : (
206
+ card
207
+ )}
208
+ {edit.dialogs}
209
+ </>
210
+ );
211
+ }
@@ -0,0 +1,153 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import { toast } from "sonner";
3
+ import { useRequestConfirm } from "../../app/src/confirm/useRequestConfirm";
4
+ import { ConfirmDialog } from "../ui/confirm-dialog";
5
+ import { Input } from "../ui/input";
6
+
7
+ export type ArtifactRenameDialogProps = {
8
+ open: boolean;
9
+ onOpenChange: (open: boolean) => void;
10
+ value: string;
11
+ onValueChange: (value: string) => void;
12
+ onSave: () => void;
13
+ pending?: boolean;
14
+ };
15
+
16
+ /** "Rename artifact" dialog with one name field. Enter saves. Save stays disabled while the name is blank. */
17
+ export function ArtifactRenameDialog({ open, onOpenChange, value, onValueChange, onSave, pending }: ArtifactRenameDialogProps) {
18
+ const blank = value.trim().length === 0;
19
+ return (
20
+ <ConfirmDialog.Root alert={false} pending={pending} open={open} onOpenChange={onOpenChange}>
21
+ <ConfirmDialog.Content className="max-w-sm">
22
+ <ConfirmDialog.Header>
23
+ <ConfirmDialog.Title>Rename artifact</ConfirmDialog.Title>
24
+ <ConfirmDialog.Description>Choose a new name.</ConfirmDialog.Description>
25
+ </ConfirmDialog.Header>
26
+ <Input
27
+ autoFocus
28
+ value={value}
29
+ aria-label="Artifact name"
30
+ onChange={(event) => onValueChange(event.target.value)}
31
+ onKeyDown={(event) => {
32
+ if (event.key !== "Enter" || blank || pending) return;
33
+ event.preventDefault();
34
+ onSave();
35
+ }}
36
+ />
37
+ <ConfirmDialog.Footer>
38
+ <ConfirmDialog.Cancel variant="secondary" />
39
+ <ConfirmDialog.Action disabled={blank} onClick={onSave}>
40
+ Save
41
+ </ConfirmDialog.Action>
42
+ </ConfirmDialog.Footer>
43
+ </ConfirmDialog.Content>
44
+ </ConfirmDialog.Root>
45
+ );
46
+ }
47
+
48
+ export type ArtifactDeleteDialogProps = {
49
+ open: boolean;
50
+ onOpenChange: (open: boolean) => void;
51
+ title: string;
52
+ onConfirm: () => void;
53
+ pending?: boolean;
54
+ };
55
+
56
+ /** "Delete <title>?" confirmation. Cancel is focused on open. */
57
+ export function ArtifactDeleteDialog({ open, onOpenChange, title, onConfirm, pending }: ArtifactDeleteDialogProps) {
58
+ return (
59
+ <ConfirmDialog.Root alert={false} focusCancel pending={pending} open={open} onOpenChange={onOpenChange}>
60
+ <ConfirmDialog.Content className="max-w-sm" role="alertdialog">
61
+ <ConfirmDialog.Header>
62
+ <ConfirmDialog.Title>Delete &quot;{title}&quot;?</ConfirmDialog.Title>
63
+ <ConfirmDialog.Description>This cannot be undone.</ConfirmDialog.Description>
64
+ </ConfirmDialog.Header>
65
+ <ConfirmDialog.Footer>
66
+ <ConfirmDialog.Cancel variant="secondary" />
67
+ <ConfirmDialog.Action variant="destructive" onClick={onConfirm}>
68
+ Delete
69
+ </ConfirmDialog.Action>
70
+ </ConfirmDialog.Footer>
71
+ </ConfirmDialog.Content>
72
+ </ConfirmDialog.Root>
73
+ );
74
+ }
75
+
76
+ export type ArtifactEditCallbacks = {
77
+ /** Called with the trimmed new title. The dialog closes when it resolves and stays open when it rejects. */
78
+ onRename?: (title: string) => void | Promise<void>;
79
+ /** Called after the delete dialog, or straight away on an Alt-click. */
80
+ onDelete?: () => void | Promise<void>;
81
+ };
82
+
83
+ /**
84
+ * Rename and delete dialog state shared by the card and the viewer. `dialogs`
85
+ * must be rendered; `requestDelete(event)` skips the dialog on an Alt-click.
86
+ */
87
+ export function useArtifactEditDialogs({ title, onRename, onDelete }: ArtifactEditCallbacks & { title: string }) {
88
+ const [renameOpen, setRenameOpen] = useState(false);
89
+ const [renameValue, setRenameValue] = useState(title);
90
+ const [deleteOpen, setDeleteOpen] = useState(false);
91
+ const [pending, setPending] = useState(false);
92
+ const { request } = useRequestConfirm();
93
+
94
+ const run = async (action: () => void | Promise<void>, failure: string, close: () => void) => {
95
+ setPending(true);
96
+ try {
97
+ await action();
98
+ close();
99
+ } catch {
100
+ toast.error(failure);
101
+ } finally {
102
+ setPending(false);
103
+ }
104
+ };
105
+
106
+ const rename = () => {
107
+ const next = renameValue.trim();
108
+ if (!onRename || next.length === 0) return;
109
+ if (next === title) {
110
+ setRenameOpen(false);
111
+ return;
112
+ }
113
+ void run(() => onRename(next), "Couldn't rename artifact", () => setRenameOpen(false));
114
+ };
115
+
116
+ const remove = () => {
117
+ if (!onDelete) return;
118
+ void run(onDelete, "Couldn't delete artifact", () => setDeleteOpen(false));
119
+ };
120
+
121
+ const dialogs: ReactNode = (
122
+ <>
123
+ {onRename ? (
124
+ <ArtifactRenameDialog
125
+ open={renameOpen}
126
+ onOpenChange={setRenameOpen}
127
+ value={renameValue}
128
+ onValueChange={setRenameValue}
129
+ onSave={rename}
130
+ pending={pending}
131
+ />
132
+ ) : null}
133
+ {onDelete ? (
134
+ <ArtifactDeleteDialog
135
+ open={deleteOpen}
136
+ onOpenChange={setDeleteOpen}
137
+ title={title}
138
+ onConfirm={remove}
139
+ pending={pending}
140
+ />
141
+ ) : null}
142
+ </>
143
+ );
144
+
145
+ return {
146
+ startRename: () => {
147
+ setRenameValue(title);
148
+ setRenameOpen(true);
149
+ },
150
+ requestDelete: (event?: { altKey?: boolean }) => request(() => setDeleteOpen(true), remove, event),
151
+ dialogs,
152
+ };
153
+ }
@@ -0,0 +1,26 @@
1
+ import { toast } from "sonner";
2
+ import { ARTIFACT_FORMAT_FILES, artifactFileName, type ArtifactFormat } from "@vvedantb/server";
3
+
4
+ /** Saves `body` as a file named after `title`, with the extension and MIME type of `format`. */
5
+ export function downloadArtifactBody(title: string, format: ArtifactFormat, body: string): void {
6
+ const url = URL.createObjectURL(new Blob([body], { type: ARTIFACT_FORMAT_FILES[format].mime }));
7
+ const link = document.createElement("a");
8
+ link.href = url;
9
+ link.download = artifactFileName(title, format);
10
+ link.click();
11
+ URL.revokeObjectURL(url);
12
+ }
13
+
14
+ /** Copies `body` to the clipboard and toasts the outcome. */
15
+ export async function copyArtifactBody(body: string): Promise<void> {
16
+ try {
17
+ await navigator.clipboard.writeText(body);
18
+ toast.success("Copied");
19
+ } catch {
20
+ toast.error("Couldn't copy");
21
+ }
22
+ }
23
+
24
+ export function openArtifactInNewTab(href: string): void {
25
+ window.open(href, "_blank", "noopener");
26
+ }
@@ -0,0 +1,195 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, type ComponentProps } from "react";
4
+ import { z } from "zod";
5
+ import {
6
+ ARTIFACT_PORT_TYPE,
7
+ ARTIFACT_READY_TYPE,
8
+ ARTIFACT_STATE_DEBOUNCE_MS,
9
+ ARTIFACT_STATE_MAX_WAIT_MS,
10
+ ARTIFACT_STATE_RESULT_TYPE,
11
+ ARTIFACT_STATE_TYPE,
12
+ applyArtifactStateOp,
13
+ createTrailingMaxDebounce,
14
+ parseArtifactStateOp,
15
+ wrapArtifactDocument,
16
+ type ArtifactFormat,
17
+ type ArtifactStateMap,
18
+ type ArtifactTemplateName,
19
+ } from "@vvedantb/server";
20
+ import { cn } from "../utils/cn";
21
+
22
+ /**
23
+ * Where one user's state for one artifact lives. `load` runs once per
24
+ * document; `save` gets the whole map (replace, not merge) after the debounce
25
+ * and on `pagehide` or unmount. Keep the object stable for an artifact: a new
26
+ * store reloads the frame.
27
+ */
28
+ export type ArtifactStateStore = {
29
+ load(): Promise<ArtifactStateMap>;
30
+ save(data: ArtifactStateMap): Promise<void>;
31
+ /** A failed load or save. After a failed load the frame starts empty and never saves, so it cannot overwrite the stored state. */
32
+ onError?(error: Error, phase: "load" | "save"): void;
33
+ };
34
+
35
+ export type ArtifactFrameProps = Omit<ComponentProps<"iframe">, "src" | "srcDoc" | "sandbox" | "title" | "ref"> & {
36
+ body: string;
37
+ format: ArtifactFormat;
38
+ /** The iframe's accessible name, and the heading of a built-in template when the body has none. */
39
+ title: string;
40
+ /** From `stateSpec.template`. */
41
+ template?: ArtifactTemplateName;
42
+ /** Without a store the state starts empty and lives until the frame unmounts. */
43
+ store?: ArtifactStateStore;
44
+ /** Extra globals for the guest state API, e.g. `["app"]` for `window.app.state`. */
45
+ stateGlobalAliases?: readonly string[];
46
+ /** Save this long after the last change. Default `ARTIFACT_STATE_DEBOUNCE_MS` (5 s). */
47
+ saveDelayMs?: number;
48
+ /** Save at least this often while changes keep coming. Default `ARTIFACT_STATE_MAX_WAIT_MS` (30 s). */
49
+ saveMaxWaitMs?: number;
50
+ };
51
+
52
+ function toError(reason: Error | string | object | null | undefined): Error {
53
+ return reason instanceof Error ? reason : new Error(String(reason));
54
+ }
55
+
56
+ // The save in flight for each store, so a reload (another version, say) reads after it.
57
+ const savesInFlight = new WeakMap<ArtifactStateStore, Promise<void>>();
58
+
59
+ /**
60
+ * Renders an artifact in an iframe with `sandbox="allow-scripts"` (never
61
+ * `allow-same-origin`) and the CSP from `wrapArtifactDocument`, and answers
62
+ * the guest's `window.artifactState` calls over a MessageChannel. State
63
+ * comes from `store.load()` before the document is written, so the guest
64
+ * starts with its snapshot; changes are saved through `store.save()`.
65
+ */
66
+ export function ArtifactFrame({
67
+ body,
68
+ format,
69
+ title,
70
+ template,
71
+ store,
72
+ stateGlobalAliases,
73
+ saveDelayMs = ARTIFACT_STATE_DEBOUNCE_MS,
74
+ saveMaxWaitMs = ARTIFACT_STATE_MAX_WAIT_MS,
75
+ className,
76
+ ...props
77
+ }: ArtifactFrameProps) {
78
+ const iframeRef = useRef<HTMLIFrameElement>(null);
79
+ // A string, so a new array with the same names does not reload the frame.
80
+ const aliasKey = (stateGlobalAliases ?? []).join(",");
81
+
82
+ useEffect(() => {
83
+ const iframe = iframeRef.current;
84
+ if (!iframe) return;
85
+ const readyMessage = z.object({ type: z.literal(ARTIFACT_READY_TYPE) });
86
+ const stateMessage = z.object({
87
+ type: z.literal(ARTIFACT_STATE_TYPE),
88
+ requestId: z.number(),
89
+ op: z.string(),
90
+ key: z.string().optional(),
91
+ value: z.string().optional(),
92
+ });
93
+ let cancelled = false;
94
+ let canSave = store !== undefined;
95
+ let data: ArtifactStateMap = {};
96
+ let port: MessagePort | undefined;
97
+
98
+ const persist = createTrailingMaxDebounce(
99
+ () => {
100
+ if (!store || !canSave) return;
101
+ savesInFlight.set(
102
+ store,
103
+ store.save(data).catch((reason) => store.onError?.(toError(reason), "save")),
104
+ );
105
+ },
106
+ saveDelayMs,
107
+ saveMaxWaitMs,
108
+ );
109
+
110
+ const answer = (event: MessageEvent) => {
111
+ const parsed = stateMessage.safeParse(event.data);
112
+ if (!parsed.success) return;
113
+ const { requestId } = parsed.data;
114
+ const op = parseArtifactStateOp(parsed.data);
115
+ const result = op ? applyArtifactStateOp(data, op) : { data, value: null, error: "Malformed state request." };
116
+ if (op && !result.error) {
117
+ data = result.data;
118
+ if (op.op === "set" || op.op === "remove" || op.op === "reset") persist.schedule();
119
+ }
120
+ port?.postMessage({
121
+ type: ARTIFACT_STATE_RESULT_TYPE,
122
+ requestId,
123
+ ok: !result.error,
124
+ value: result.value,
125
+ error: result.error,
126
+ });
127
+ };
128
+
129
+ const onWindowMessage = (event: MessageEvent) => {
130
+ const guest = iframe.contentWindow;
131
+ if (!guest || event.source !== guest || !readyMessage.safeParse(event.data).success) return;
132
+ // A new document (or a reload of this one) gets a fresh channel.
133
+ port?.close();
134
+ const channel = new MessageChannel();
135
+ port = channel.port1;
136
+ port.onmessage = answer;
137
+ guest.postMessage({ type: ARTIFACT_PORT_TYPE }, "*", [channel.port2]);
138
+ };
139
+
140
+ const write = (snapshot: ArtifactStateMap) => {
141
+ iframe.srcdoc = wrapArtifactDocument(body, format, snapshot, {
142
+ title,
143
+ template,
144
+ stateGlobalAliases: aliasKey === "" ? [] : aliasKey.split(","),
145
+ });
146
+ };
147
+
148
+ const flush = () => persist.flushNow();
149
+ const onVisibility = () => {
150
+ if (document.visibilityState === "hidden") flush();
151
+ };
152
+
153
+ window.addEventListener("message", onWindowMessage);
154
+ window.addEventListener("pagehide", flush);
155
+ document.addEventListener("visibilitychange", onVisibility);
156
+
157
+ if (store) {
158
+ (savesInFlight.get(store) ?? Promise.resolve()).then(() => store.load()).then(
159
+ (loaded) => {
160
+ if (cancelled) return;
161
+ data = loaded;
162
+ write(loaded);
163
+ },
164
+ (reason) => {
165
+ if (cancelled) return;
166
+ canSave = false;
167
+ store.onError?.(toError(reason), "load");
168
+ write({});
169
+ },
170
+ );
171
+ } else {
172
+ write({});
173
+ }
174
+
175
+ return () => {
176
+ cancelled = true;
177
+ persist.flushNow();
178
+ persist.cancel();
179
+ window.removeEventListener("message", onWindowMessage);
180
+ window.removeEventListener("pagehide", flush);
181
+ document.removeEventListener("visibilitychange", onVisibility);
182
+ port?.close();
183
+ };
184
+ }, [body, format, title, template, store, aliasKey, saveDelayMs, saveMaxWaitMs]);
185
+
186
+ return (
187
+ <iframe
188
+ {...props}
189
+ ref={iframeRef}
190
+ title={title}
191
+ sandbox="allow-scripts"
192
+ className={cn("block border-0 bg-white", className)}
193
+ />
194
+ );
195
+ }
@@ -0,0 +1,110 @@
1
+ import type { ReactNode } from "react";
2
+ import { IconChevronDown, IconFilter, IconLayoutGrid, IconList } from "@tabler/icons-react";
3
+ import { ARTIFACT_TYPES, type ArtifactType } from "@vvedantb/server";
4
+ import { Button } from "../ui/button";
5
+ import {
6
+ DropdownMenu,
7
+ DropdownMenuContent,
8
+ DropdownMenuRadioGroup,
9
+ DropdownMenuRadioItem,
10
+ DropdownMenuSeparator,
11
+ DropdownMenuTrigger,
12
+ } from "../ui/dropdown-menu";
13
+ import { ToolbarSearchInput } from "../ui/toolbar-search-input";
14
+ import { cn } from "../utils/cn";
15
+ import type { ArtifactLayout } from "./artifact-card";
16
+ import { ARTIFACT_TYPE_LABELS, ArtifactTypeIcon } from "./artifact-type-icon";
17
+
18
+ const ALL = "all";
19
+ const LAYOUTS: readonly ArtifactLayout[] = ["grid", "list"];
20
+
21
+ export type ArtifactListToolbarProps = {
22
+ query: string;
23
+ onQueryChange: (query: string) => void;
24
+ /** Selected type; `undefined` is "All types". */
25
+ type?: ArtifactType;
26
+ /** Omit to hide the type filter. */
27
+ onTypeChange?: (type: ArtifactType | undefined) => void;
28
+ /** Types offered in the filter. Default every `ARTIFACT_TYPES` entry. */
29
+ types?: readonly ArtifactType[];
30
+ layout?: ArtifactLayout;
31
+ /** Omit to hide the grid and list toggle. */
32
+ onLayoutChange?: (layout: ArtifactLayout) => void;
33
+ /** Default "Search artifacts...". */
34
+ searchPlaceholder?: string;
35
+ /** Extra controls at the end, e.g. a create button. */
36
+ children?: ReactNode;
37
+ className?: string;
38
+ };
39
+
40
+ /** Search field, type filter and grid or list toggle above an `ArtifactList`. Controlled: it holds no state. */
41
+ export function ArtifactListToolbar({
42
+ query,
43
+ onQueryChange,
44
+ type,
45
+ onTypeChange,
46
+ types = ARTIFACT_TYPES,
47
+ layout = "grid",
48
+ onLayoutChange,
49
+ searchPlaceholder = "Search artifacts...",
50
+ children,
51
+ className,
52
+ }: ArtifactListToolbarProps) {
53
+ return (
54
+ <div className={cn("flex flex-wrap items-center gap-2", className)}>
55
+ <ToolbarSearchInput
56
+ value={query}
57
+ onChange={onQueryChange}
58
+ placeholder={searchPlaceholder}
59
+ label="Search artifacts"
60
+ />
61
+ {onTypeChange ? (
62
+ <DropdownMenu>
63
+ <DropdownMenuTrigger asChild>
64
+ <Button
65
+ variant="outline"
66
+ size="sm"
67
+ aria-label="Filter by type"
68
+ startContent={type ? <ArtifactTypeIcon type={type} /> : <IconFilter size={16} />}
69
+ endContent={<IconChevronDown size={14} className="text-muted-foreground" />}
70
+ >
71
+ {type ? ARTIFACT_TYPE_LABELS[type] : "All types"}
72
+ </Button>
73
+ </DropdownMenuTrigger>
74
+ <DropdownMenuContent fitViewport suppressAltKey restoreScrollLockOnClose align="start">
75
+ <DropdownMenuRadioGroup
76
+ value={type ?? ALL}
77
+ onValueChange={(value) => onTypeChange(types.find((candidate) => candidate === value))}
78
+ >
79
+ <DropdownMenuRadioItem value={ALL}>All types</DropdownMenuRadioItem>
80
+ <DropdownMenuSeparator />
81
+ {types.map((candidate) => (
82
+ <DropdownMenuRadioItem key={candidate} value={candidate}>
83
+ {ARTIFACT_TYPE_LABELS[candidate]}
84
+ </DropdownMenuRadioItem>
85
+ ))}
86
+ </DropdownMenuRadioGroup>
87
+ </DropdownMenuContent>
88
+ </DropdownMenu>
89
+ ) : null}
90
+ {onLayoutChange ? (
91
+ <div className="flex items-center gap-1">
92
+ {LAYOUTS.map((option) => (
93
+ <Button
94
+ key={option}
95
+ size="icon-sm"
96
+ variant="ghost"
97
+ aria-label={option === "grid" ? "Grid view" : "List view"}
98
+ aria-pressed={layout === option}
99
+ className={layout === option ? "bg-default text-foreground" : undefined}
100
+ onClick={() => onLayoutChange(option)}
101
+ >
102
+ {option === "grid" ? <IconLayoutGrid size={16} /> : <IconList size={16} />}
103
+ </Button>
104
+ ))}
105
+ </div>
106
+ ) : null}
107
+ {children ? <div className="ml-auto flex items-center gap-2">{children}</div> : null}
108
+ </div>
109
+ );
110
+ }