@vvedantb/ui 0.12.1 → 0.13.1
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.
- package/README.md +51 -2
- package/app/README.md +259 -0
- package/package.json +17 -11
- package/src/artifacts/artifact-card.tsx +215 -0
- package/src/artifacts/artifact-dialogs.tsx +165 -0
- package/src/artifacts/artifact-file.ts +26 -0
- package/src/artifacts/artifact-frame.tsx +195 -0
- package/src/artifacts/artifact-list-toolbar.tsx +110 -0
- package/src/artifacts/artifact-list.tsx +54 -0
- package/src/artifacts/artifact-render.tsx +50 -0
- package/src/artifacts/artifact-type-icon.tsx +61 -0
- package/src/artifacts/artifact-versions-menu.tsx +103 -0
- package/src/artifacts/artifact-viewer.tsx +134 -0
- package/src/artifacts/index.ts +11 -0
- package/src/artifacts/types.ts +18 -0
- package/src/index.ts +4 -0
- package/tokens/README.md +98 -0
|
@@ -0,0 +1,165 @@
|
|
|
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
|
+
/** Text under the title. Defaults to "This cannot be undone." */
|
|
55
|
+
description?: ReactNode;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** "Delete <title>?" confirmation. Cancel is focused on open. */
|
|
59
|
+
export function ArtifactDeleteDialog({
|
|
60
|
+
open,
|
|
61
|
+
onOpenChange,
|
|
62
|
+
title,
|
|
63
|
+
onConfirm,
|
|
64
|
+
pending,
|
|
65
|
+
description = "This cannot be undone.",
|
|
66
|
+
}: ArtifactDeleteDialogProps) {
|
|
67
|
+
return (
|
|
68
|
+
<ConfirmDialog.Root alert={false} focusCancel pending={pending} open={open} onOpenChange={onOpenChange}>
|
|
69
|
+
<ConfirmDialog.Content className="max-w-sm" role="alertdialog">
|
|
70
|
+
<ConfirmDialog.Header>
|
|
71
|
+
<ConfirmDialog.Title>Delete "{title}"?</ConfirmDialog.Title>
|
|
72
|
+
<ConfirmDialog.Description>{description}</ConfirmDialog.Description>
|
|
73
|
+
</ConfirmDialog.Header>
|
|
74
|
+
<ConfirmDialog.Footer>
|
|
75
|
+
<ConfirmDialog.Cancel variant="secondary" />
|
|
76
|
+
<ConfirmDialog.Action variant="destructive" onClick={onConfirm}>
|
|
77
|
+
Delete
|
|
78
|
+
</ConfirmDialog.Action>
|
|
79
|
+
</ConfirmDialog.Footer>
|
|
80
|
+
</ConfirmDialog.Content>
|
|
81
|
+
</ConfirmDialog.Root>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type ArtifactEditCallbacks = {
|
|
86
|
+
/** Called with the trimmed new title. The dialog closes when it resolves and stays open when it rejects. */
|
|
87
|
+
onRename?: (title: string) => void | Promise<void>;
|
|
88
|
+
/** Called after the delete dialog, or straight away on an Alt-click. */
|
|
89
|
+
onDelete?: () => void | Promise<void>;
|
|
90
|
+
/** Text under the delete dialog's title, e.g. for a host that can undo. Defaults to "This cannot be undone." */
|
|
91
|
+
deleteDescription?: ReactNode;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Rename and delete dialog state shared by the card and the viewer. `dialogs`
|
|
96
|
+
* must be rendered; `requestDelete(event)` skips the dialog on an Alt-click.
|
|
97
|
+
*/
|
|
98
|
+
export function useArtifactEditDialogs({ title, onRename, onDelete, deleteDescription }: ArtifactEditCallbacks & { title: string }) {
|
|
99
|
+
const [renameOpen, setRenameOpen] = useState(false);
|
|
100
|
+
const [renameValue, setRenameValue] = useState(title);
|
|
101
|
+
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
102
|
+
const [pending, setPending] = useState(false);
|
|
103
|
+
const { request } = useRequestConfirm();
|
|
104
|
+
|
|
105
|
+
const run = async (action: () => void | Promise<void>, failure: string, close: () => void) => {
|
|
106
|
+
setPending(true);
|
|
107
|
+
try {
|
|
108
|
+
await action();
|
|
109
|
+
close();
|
|
110
|
+
} catch {
|
|
111
|
+
toast.error(failure);
|
|
112
|
+
} finally {
|
|
113
|
+
setPending(false);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const rename = () => {
|
|
118
|
+
const next = renameValue.trim();
|
|
119
|
+
if (!onRename || next.length === 0) return;
|
|
120
|
+
if (next === title) {
|
|
121
|
+
setRenameOpen(false);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
void run(() => onRename(next), "Couldn't rename artifact", () => setRenameOpen(false));
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const remove = () => {
|
|
128
|
+
if (!onDelete) return;
|
|
129
|
+
void run(onDelete, "Couldn't delete artifact", () => setDeleteOpen(false));
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const dialogs: ReactNode = (
|
|
133
|
+
<>
|
|
134
|
+
{onRename ? (
|
|
135
|
+
<ArtifactRenameDialog
|
|
136
|
+
open={renameOpen}
|
|
137
|
+
onOpenChange={setRenameOpen}
|
|
138
|
+
value={renameValue}
|
|
139
|
+
onValueChange={setRenameValue}
|
|
140
|
+
onSave={rename}
|
|
141
|
+
pending={pending}
|
|
142
|
+
/>
|
|
143
|
+
) : null}
|
|
144
|
+
{onDelete ? (
|
|
145
|
+
<ArtifactDeleteDialog
|
|
146
|
+
open={deleteOpen}
|
|
147
|
+
onOpenChange={setDeleteOpen}
|
|
148
|
+
title={title}
|
|
149
|
+
onConfirm={remove}
|
|
150
|
+
pending={pending}
|
|
151
|
+
description={deleteDescription}
|
|
152
|
+
/>
|
|
153
|
+
) : null}
|
|
154
|
+
</>
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
startRename: () => {
|
|
159
|
+
setRenameValue(title);
|
|
160
|
+
setRenameOpen(true);
|
|
161
|
+
},
|
|
162
|
+
requestDelete: (event?: { altKey?: boolean }) => request(() => setDeleteOpen(true), remove, event),
|
|
163
|
+
dialogs,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -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
|
+
}
|