@vvedantb/ui 0.12.1 → 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,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
+ }
@@ -0,0 +1,54 @@
1
+ import type { ReactNode } from "react";
2
+ import { IconLayoutDashboard } from "@tabler/icons-react";
3
+ import { EmptyState } from "../ui/empty-state";
4
+ import { cn } from "../utils/cn";
5
+ import { ArtifactCard, type ArtifactCardActions, type ArtifactLayout } from "./artifact-card";
6
+ import type { ArtifactSummary } from "./types";
7
+
8
+ export type ArtifactListProps = ArtifactCardActions & {
9
+ artifacts: readonly ArtifactSummary[];
10
+ /** `"grid"` (default): tiles in up to three columns. `"list"`: compact rows. */
11
+ layout?: ArtifactLayout;
12
+ /** Default "No artifacts yet". */
13
+ emptyTitle?: ReactNode;
14
+ /** Default "Artifacts you save show up here." */
15
+ emptyDescription?: ReactNode;
16
+ /** Buttons under the empty message, e.g. a create button. */
17
+ emptyActions?: ReactNode;
18
+ className?: string;
19
+ };
20
+
21
+ /** Artifacts as a grid or a list of `ArtifactCard`s, or an empty state when there are none. */
22
+ export function ArtifactList({
23
+ artifacts,
24
+ layout = "grid",
25
+ emptyTitle = "No artifacts yet",
26
+ emptyDescription = "Artifacts you save show up here.",
27
+ emptyActions,
28
+ className,
29
+ ...actions
30
+ }: ArtifactListProps) {
31
+ if (artifacts.length === 0) {
32
+ return (
33
+ <EmptyState.Root className={cn("min-h-80 flex-1", className)}>
34
+ <EmptyState.Icon icon={IconLayoutDashboard} size={28} className="opacity-50" />
35
+ <EmptyState.Title>{emptyTitle}</EmptyState.Title>
36
+ <EmptyState.Description className="max-w-sm text-xs leading-relaxed">{emptyDescription}</EmptyState.Description>
37
+ {emptyActions ? <EmptyState.Actions>{emptyActions}</EmptyState.Actions> : null}
38
+ </EmptyState.Root>
39
+ );
40
+ }
41
+
42
+ return (
43
+ <div
44
+ className={cn(
45
+ layout === "list" ? "flex flex-col gap-0.5" : "grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3",
46
+ className,
47
+ )}
48
+ >
49
+ {artifacts.map((artifact) => (
50
+ <ArtifactCard key={artifact.id} artifact={artifact} layout={layout} {...actions} />
51
+ ))}
52
+ </div>
53
+ );
54
+ }
@@ -0,0 +1,50 @@
1
+ import { cjk } from "@streamdown/cjk";
2
+ import { code } from "@streamdown/code";
3
+ import { math } from "@streamdown/math";
4
+ import { mermaid } from "@streamdown/mermaid";
5
+ import { Streamdown } from "streamdown";
6
+ import { CodeBlock } from "../ai-elements/code-block";
7
+ import { cn } from "../utils/cn";
8
+ import { ArtifactFrame, type ArtifactFrameProps } from "./artifact-frame";
9
+ import type { ArtifactDocument } from "./types";
10
+
11
+ const streamdownPlugins = { cjk, code, math, mermaid };
12
+
13
+ export type ArtifactRenderProps = Pick<ArtifactFrameProps, "store" | "stateGlobalAliases"> & {
14
+ artifact: Pick<ArtifactDocument, "title" | "format" | "language" | "stateSpec" | "body">;
15
+ className?: string;
16
+ };
17
+
18
+ /**
19
+ * An artifact body by format: Markdown with Streamdown, code with `CodeBlock`,
20
+ * and HTML, SVG and typed JSON (quiz, flashcards) in an `ArtifactFrame`.
21
+ */
22
+ export function ArtifactRender({ artifact, store, stateGlobalAliases, className }: ArtifactRenderProps) {
23
+ if (artifact.format === "markdown") {
24
+ return (
25
+ <div className={cn("overflow-auto p-4", className)}>
26
+ <Streamdown plugins={streamdownPlugins}>{artifact.body}</Streamdown>
27
+ </div>
28
+ );
29
+ }
30
+ if (artifact.format === "code") {
31
+ return (
32
+ <CodeBlock
33
+ code={artifact.body}
34
+ language={artifact.language ?? "text"}
35
+ className={cn("overflow-auto rounded-none", className)}
36
+ />
37
+ );
38
+ }
39
+ return (
40
+ <ArtifactFrame
41
+ body={artifact.body}
42
+ format={artifact.format}
43
+ title={artifact.title}
44
+ template={artifact.stateSpec?.template}
45
+ store={store}
46
+ stateGlobalAliases={stateGlobalAliases}
47
+ className={cn("h-72 w-full", className)}
48
+ />
49
+ );
50
+ }
@@ -0,0 +1,61 @@
1
+ import {
2
+ IconCards,
3
+ IconChartInfographic,
4
+ IconCode,
5
+ IconFileText,
6
+ IconListCheck,
7
+ IconPresentation,
8
+ IconReport,
9
+ IconSchema,
10
+ IconSitemap,
11
+ type TablerIcon,
12
+ } from "@tabler/icons-react";
13
+ import type { ArtifactType } from "@vvedantb/server";
14
+ import { cn } from "../utils/cn";
15
+
16
+ const ICONS: Record<ArtifactType, TablerIcon> = {
17
+ document: IconFileText,
18
+ report: IconReport,
19
+ slides: IconPresentation,
20
+ infographic: IconChartInfographic,
21
+ mindmap: IconSitemap,
22
+ quiz: IconListCheck,
23
+ flashcards: IconCards,
24
+ diagram: IconSchema,
25
+ code: IconCode,
26
+ };
27
+
28
+ /** Display name of each artifact type, for filters and labels. */
29
+ export const ARTIFACT_TYPE_LABELS: Record<ArtifactType, string> = {
30
+ document: "Document",
31
+ report: "Report",
32
+ slides: "Slides",
33
+ infographic: "Infographic",
34
+ mindmap: "Mind map",
35
+ quiz: "Quiz",
36
+ flashcards: "Flashcards",
37
+ diagram: "Diagram",
38
+ code: "Code",
39
+ };
40
+
41
+ export type ArtifactTypeIconProps = {
42
+ type: ArtifactType;
43
+ /** Pixel size. Default 16. */
44
+ size?: number;
45
+ stroke?: number;
46
+ className?: string;
47
+ };
48
+
49
+ /** Tabler icon for an artifact type. Decorative (`aria-hidden`); muted by default. */
50
+ export function ArtifactTypeIcon({ type, size = 16, stroke, className }: ArtifactTypeIconProps) {
51
+ const TypeIcon = ICONS[type];
52
+ return (
53
+ <TypeIcon
54
+ size={size}
55
+ stroke={stroke}
56
+ aria-hidden
57
+ data-artifact-type={type}
58
+ className={cn("shrink-0 text-muted-foreground", className)}
59
+ />
60
+ );
61
+ }