@saadnvd1/agent-os 0.3.761 → 0.3.765
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/app/api/artifacts/[id]/route.ts +13 -4
- package/app/api/artifacts/[id]/versions/route.ts +16 -0
- package/app/api/sessions/[id]/artifacts/route.ts +9 -1
- package/app/api/shares/route.test.ts +66 -1
- package/app/api/shares/route.ts +15 -0
- package/components/Artifacts/ArtifactActions.tsx +308 -0
- package/components/Artifacts/ArtifactFullscreen.tsx +58 -0
- package/components/Artifacts/ArtifactPreview.tsx +141 -18
- package/components/Artifacts/ArtifactRow.tsx +5 -0
- package/components/Artifacts/ArtifactsPanel.tsx +2 -0
- package/components/Artifacts/VersionSwitcher.tsx +216 -0
- package/components/Chat/Artifact.tsx +129 -89
- package/components/Share/DownloadMenu.tsx +63 -38
- package/components/Share/ShareDialog.tsx +47 -0
- package/components/Shares/SharedLinksPanel.tsx +7 -0
- package/data/session-artifacts/keys.ts +3 -0
- package/data/session-artifacts/queries.ts +25 -0
- package/lib/artifacts/actions.ts +75 -0
- package/lib/artifacts/store.ts +105 -10
- package/lib/artifacts/tools.ts +33 -6
- package/lib/artifacts/versions.ts +73 -0
- package/lib/chat/events.ts +4 -0
- package/lib/chat/worker/host.ts +2 -0
- package/lib/connect/control-api.ts +15 -0
- package/lib/db/migrations/20261029T1200_artifact_versions.ts +28 -0
- package/lib/db/migrations/20261029T1300_shared_links_follow.ts +28 -0
- package/lib/security/demo.ts +1 -0
- package/lib/session-artifacts/collect.ts +28 -6
- package/lib/session-artifacts/derive.ts +3 -1
- package/lib/session-artifacts/types.ts +9 -0
- package/lib/share/connect.ts +10 -0
- package/lib/share/follow.ts +76 -0
- package/lib/share/service.ts +6 -0
- package/lib/share/store.ts +34 -2
- package/lib/share/view.ts +6 -0
- package/package.json +1 -1
- package/stores/shareUi.ts +6 -2
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
getArtifact,
|
|
4
|
+
latestVersion,
|
|
5
|
+
readArtifactHtml,
|
|
6
|
+
} from "@/lib/artifacts/store";
|
|
3
7
|
import { ARTIFACT_HEADERS, prepareArtifact } from "@/lib/artifacts/serve";
|
|
4
8
|
|
|
5
|
-
// GET /api/artifacts/[id] - a page an agent showed, sandboxed
|
|
9
|
+
// GET /api/artifacts/[id] - a page an agent showed, sandboxed. That exact
|
|
10
|
+
// version, or with ?v=latest the page's newest.
|
|
6
11
|
export async function GET(
|
|
7
|
-
|
|
12
|
+
request: Request,
|
|
8
13
|
{ params }: { params: Promise<{ id: string }> }
|
|
9
14
|
) {
|
|
10
15
|
const { id } = await params;
|
|
11
|
-
const
|
|
16
|
+
const asked = getArtifact(id);
|
|
17
|
+
const artifact =
|
|
18
|
+
asked && new URL(request.url).searchParams.get("v") === "latest"
|
|
19
|
+
? latestVersion(asked.page_id)
|
|
20
|
+
: asked;
|
|
12
21
|
const html = artifact ? await readArtifactHtml(artifact) : null;
|
|
13
22
|
if (html === null)
|
|
14
23
|
return NextResponse.json({ error: "Artifact not found" }, { status: 404 });
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { pageVersions } from "@/lib/artifacts/versions";
|
|
3
|
+
import { pdfAvailable } from "@/lib/share/pdf";
|
|
4
|
+
|
|
5
|
+
// GET /api/artifacts/[id]/versions - every version of the page this is one
|
|
6
|
+
// of, oldest first, and what can be done with it
|
|
7
|
+
export async function GET(
|
|
8
|
+
_request: Request,
|
|
9
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
10
|
+
) {
|
|
11
|
+
const { id } = await params;
|
|
12
|
+
const page = pageVersions(id, { pdf: await pdfAvailable() });
|
|
13
|
+
if (!page)
|
|
14
|
+
return NextResponse.json({ error: "Artifact not found" }, { status: 404 });
|
|
15
|
+
return NextResponse.json(page);
|
|
16
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
+
import { artifactActions } from "@/lib/artifacts/actions";
|
|
3
|
+
import { pdfAvailable } from "@/lib/share/pdf";
|
|
2
4
|
import { peerLinkOf } from "@/lib/hosts/peer-actions";
|
|
3
5
|
import { collectArtifacts, getSession } from "@/lib/session-artifacts/collect";
|
|
4
6
|
import { peerArtifacts } from "@/lib/session-artifacts/peer";
|
|
@@ -29,5 +31,11 @@ export async function GET(
|
|
|
29
31
|
}
|
|
30
32
|
}
|
|
31
33
|
const entries = await collectArtifacts(session);
|
|
32
|
-
|
|
34
|
+
const ctx = { pdf: await pdfAvailable() };
|
|
35
|
+
return NextResponse.json({
|
|
36
|
+
artifacts: entries.map((e) => ({
|
|
37
|
+
...e.artifact,
|
|
38
|
+
actions: artifactActions(e.artifact, ctx),
|
|
39
|
+
})),
|
|
40
|
+
});
|
|
33
41
|
}
|
|
@@ -117,7 +117,7 @@ const one = await import("./[id]/route");
|
|
|
117
117
|
const { saveItem } = await import("@/lib/chat/store");
|
|
118
118
|
const { seedSession } = await import("@/lib/orchestrator/testing");
|
|
119
119
|
const { createProject } = await import("@/lib/projects");
|
|
120
|
-
const { createArtifact } = await import("@/lib/artifacts/store");
|
|
120
|
+
const { createArtifact, saveArtifact } = await import("@/lib/artifacts/store");
|
|
121
121
|
const { getDb } = await import("@/lib/db");
|
|
122
122
|
|
|
123
123
|
const secret = ["gh", "p_", "b".repeat(36)].join("");
|
|
@@ -341,6 +341,71 @@ describe("create", () => {
|
|
|
341
341
|
expect(html).toContain("noindex");
|
|
342
342
|
});
|
|
343
343
|
|
|
344
|
+
describe("follow latest", () => {
|
|
345
|
+
const page = (html: string) =>
|
|
346
|
+
`<html><head></head><body>${html}</body></html>`;
|
|
347
|
+
async function create(artifactId: string, extra: object = {}) {
|
|
348
|
+
const body = { kind: "artifact", artifactId, ...extra };
|
|
349
|
+
const p = await (
|
|
350
|
+
await preview.POST(post("/api/shares/preview", body))
|
|
351
|
+
).json();
|
|
352
|
+
return shares.POST(
|
|
353
|
+
post("/api/shares", {
|
|
354
|
+
...body,
|
|
355
|
+
sharedAt: p.sharedAt,
|
|
356
|
+
hash: p.hash,
|
|
357
|
+
expiresIn: "7d",
|
|
358
|
+
})
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
const stored = () =>
|
|
362
|
+
getDb().prepare(`SELECT follow_latest, version FROM shared_links`).get();
|
|
363
|
+
|
|
364
|
+
it("pins the shown version by default", async () => {
|
|
365
|
+
const sid = chat();
|
|
366
|
+
saveArtifact(sid, "Doc", page("one"), { sameTitle: true });
|
|
367
|
+
const v2 = saveArtifact(sid, "Doc", page("two"), {
|
|
368
|
+
sameTitle: true,
|
|
369
|
+
}).artifact;
|
|
370
|
+
expect((await create(v2.id)).status).toBe(201);
|
|
371
|
+
expect(stored()).toEqual({ follow_latest: 0, version: 2 });
|
|
372
|
+
const listed = await (await shares.GET()).json();
|
|
373
|
+
expect(listed.shares[0]).toMatchObject({ follow: false, version: 2 });
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("keeps follow: latest on the link and lists it", async () => {
|
|
377
|
+
const sid = chat();
|
|
378
|
+
const v1 = saveArtifact(sid, "Doc", page("one"), {
|
|
379
|
+
sameTitle: true,
|
|
380
|
+
}).artifact;
|
|
381
|
+
expect((await create(v1.id, { follow: "latest" })).status).toBe(201);
|
|
382
|
+
expect(stored()).toEqual({ follow_latest: 1, version: 1 });
|
|
383
|
+
const listed = await (await shares.GET()).json();
|
|
384
|
+
expect(listed.shares[0]).toMatchObject({ follow: true, version: 1 });
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("refuses to follow from an older version, and for a chat", async () => {
|
|
388
|
+
const sid = chat();
|
|
389
|
+
const v1 = saveArtifact(sid, "Doc", page("one"), {
|
|
390
|
+
sameTitle: true,
|
|
391
|
+
}).artifact;
|
|
392
|
+
saveArtifact(sid, "Doc", page("two"), { sameTitle: true });
|
|
393
|
+
const old = await create(v1.id, { follow: "latest" });
|
|
394
|
+
expect(old.status).toBe(409);
|
|
395
|
+
expect((await old.json()).error).toMatch(/share v2/);
|
|
396
|
+
const res = await shares.POST(
|
|
397
|
+
post("/api/shares", {
|
|
398
|
+
kind: "chat",
|
|
399
|
+
sessionId: sid,
|
|
400
|
+
follow: "latest",
|
|
401
|
+
hash: "x",
|
|
402
|
+
})
|
|
403
|
+
);
|
|
404
|
+
expect(res.status).toBe(400);
|
|
405
|
+
expect(seen).toHaveLength(0);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
344
409
|
it("says so when Connect isn't linked, and for a bad expiry or hash", async () => {
|
|
345
410
|
const id = chat();
|
|
346
411
|
const p = await previewOf(id);
|
package/app/api/shares/route.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
buildPage,
|
|
13
13
|
parseRequest,
|
|
14
14
|
} from "@/lib/share/service";
|
|
15
|
+
import { getArtifact, latestVersion } from "@/lib/artifacts/store";
|
|
15
16
|
import { insertSharedLink, listSharedLinks } from "@/lib/share/store";
|
|
16
17
|
import type { ShareExpiry } from "@/lib/connect/control-api";
|
|
17
18
|
|
|
@@ -31,6 +32,16 @@ export async function POST(request: Request) {
|
|
|
31
32
|
if (typeof body?.hash !== "string") return fail("hash is required.", 400);
|
|
32
33
|
if (!linked()) return fail(NOT_LINKED, 409);
|
|
33
34
|
|
|
35
|
+
const artifact = req.artifactId ? getArtifact(req.artifactId) : undefined;
|
|
36
|
+
if (req.follow && artifact) {
|
|
37
|
+
const latest = latestVersion(artifact.page_id);
|
|
38
|
+
if (latest && latest.id !== artifact.id)
|
|
39
|
+
return fail(
|
|
40
|
+
`v${artifact.version} isn't the latest; share v${latest.version} to follow the page.`,
|
|
41
|
+
409
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
34
45
|
const page = await buildPage(req);
|
|
35
46
|
if (page.tooBig) return fail(TOO_BIG, 413);
|
|
36
47
|
if (page.hash !== body.hash)
|
|
@@ -53,6 +64,8 @@ export async function POST(request: Request) {
|
|
|
53
64
|
expires_at: share.expires_at,
|
|
54
65
|
created_at: share.created_at,
|
|
55
66
|
revoked_at: null,
|
|
67
|
+
follow_latest: req.follow ? 1 : 0,
|
|
68
|
+
version: artifact?.version ?? null,
|
|
56
69
|
});
|
|
57
70
|
return NextResponse.json(
|
|
58
71
|
{ id: share.id, url: share.url, expiresAt: share.expires_at },
|
|
@@ -96,6 +109,8 @@ export async function GET() {
|
|
|
96
109
|
status,
|
|
97
110
|
views: r ? r.view_count : null,
|
|
98
111
|
lastViewedAt: r?.last_viewed_at ?? null,
|
|
112
|
+
follow: l.follow_latest === 1,
|
|
113
|
+
version: l.version,
|
|
99
114
|
};
|
|
100
115
|
}),
|
|
101
116
|
});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
Download,
|
|
7
|
+
ExternalLink,
|
|
8
|
+
Loader2,
|
|
9
|
+
Maximize2,
|
|
10
|
+
MoreHorizontal,
|
|
11
|
+
Share2,
|
|
12
|
+
} from "lucide-react";
|
|
13
|
+
import { Button } from "@/components/ui/button";
|
|
14
|
+
import {
|
|
15
|
+
DropdownMenu,
|
|
16
|
+
DropdownMenuContent,
|
|
17
|
+
DropdownMenuItem,
|
|
18
|
+
DropdownMenuTrigger,
|
|
19
|
+
} from "@/components/ui/dropdown-menu";
|
|
20
|
+
import { artifactUrl } from "@/components/Chat/ArtifactFrame";
|
|
21
|
+
import {
|
|
22
|
+
DownloadMenu,
|
|
23
|
+
useShareDownload,
|
|
24
|
+
} from "@/components/Share/DownloadMenu";
|
|
25
|
+
import type { PreviewRequest } from "@/data/shares";
|
|
26
|
+
import type { ArtifactAction, DownloadFormat } from "@/lib/artifacts/actions";
|
|
27
|
+
import { shareUiActions } from "@/stores/shareUi";
|
|
28
|
+
import { cn } from "@/lib/utils";
|
|
29
|
+
|
|
30
|
+
export const ICON_BUTTON =
|
|
31
|
+
"text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] inline-flex h-11 w-11 items-center justify-center rounded-lg md:h-8 md:w-8";
|
|
32
|
+
|
|
33
|
+
const FORMAT_LABEL: Record<DownloadFormat, string> = {
|
|
34
|
+
pdf: "Download PDF",
|
|
35
|
+
html: "Download HTML page",
|
|
36
|
+
md: "Download Markdown",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
interface Props {
|
|
40
|
+
// The exact version every action acts on.
|
|
41
|
+
artifactId: string;
|
|
42
|
+
title: string;
|
|
43
|
+
// Named in the share dialog when the page has more than one.
|
|
44
|
+
version?: number;
|
|
45
|
+
actions: ArtifactAction[];
|
|
46
|
+
// icons: the chat card's title bar. labelled: the Artifacts view's header.
|
|
47
|
+
// bar: the Artifacts view's bottom bar on a phone.
|
|
48
|
+
variant: "icons" | "labelled" | "bar";
|
|
49
|
+
onFull?: () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A rendered page's actions: full screen, share, open, download. One place
|
|
53
|
+
// for the chat card and the Artifacts view, from the server's list.
|
|
54
|
+
export function ArtifactActions({
|
|
55
|
+
artifactId,
|
|
56
|
+
title,
|
|
57
|
+
version,
|
|
58
|
+
actions,
|
|
59
|
+
variant,
|
|
60
|
+
onFull,
|
|
61
|
+
}: Props) {
|
|
62
|
+
const has = (id: ArtifactAction["id"]) => actions.some((a) => a.id === id);
|
|
63
|
+
const formats = actions.find((a) => a.id === "download")?.formats ?? [];
|
|
64
|
+
const full = has("fullscreen") && onFull;
|
|
65
|
+
const request: PreviewRequest = {
|
|
66
|
+
kind: "artifact",
|
|
67
|
+
artifactId,
|
|
68
|
+
includeTools: false,
|
|
69
|
+
};
|
|
70
|
+
const href = artifactUrl(artifactId);
|
|
71
|
+
const share = () =>
|
|
72
|
+
shareUiActions.open({ kind: "artifact", artifactId, title, version });
|
|
73
|
+
|
|
74
|
+
if (variant === "bar")
|
|
75
|
+
return (
|
|
76
|
+
<div className="flex gap-2">
|
|
77
|
+
{has("share") && (
|
|
78
|
+
<Button className="h-11 flex-1" onClick={share}>
|
|
79
|
+
<Share2 className="h-4 w-4" /> Share
|
|
80
|
+
</Button>
|
|
81
|
+
)}
|
|
82
|
+
{has("open") && (
|
|
83
|
+
<Button variant="outline" className="h-11 flex-1" asChild>
|
|
84
|
+
<a href={href} target="_blank" rel="noopener noreferrer">
|
|
85
|
+
<ExternalLink className="h-4 w-4" /> Open
|
|
86
|
+
</a>
|
|
87
|
+
</Button>
|
|
88
|
+
)}
|
|
89
|
+
{full && (
|
|
90
|
+
<Button
|
|
91
|
+
variant="outline"
|
|
92
|
+
className="h-11 w-11 shrink-0"
|
|
93
|
+
onClick={onFull}
|
|
94
|
+
aria-label="Full screen"
|
|
95
|
+
>
|
|
96
|
+
<Maximize2 className="h-4 w-4" />
|
|
97
|
+
</Button>
|
|
98
|
+
)}
|
|
99
|
+
{formats.length > 0 && (
|
|
100
|
+
<More request={request} title={title} formats={formats}>
|
|
101
|
+
{(pending) => (
|
|
102
|
+
<Button
|
|
103
|
+
variant="outline"
|
|
104
|
+
className="h-11 w-11 shrink-0"
|
|
105
|
+
aria-label="More"
|
|
106
|
+
>
|
|
107
|
+
{pending ? (
|
|
108
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
109
|
+
) : (
|
|
110
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
111
|
+
)}
|
|
112
|
+
</Button>
|
|
113
|
+
)}
|
|
114
|
+
</More>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
if (variant === "labelled") {
|
|
120
|
+
const label = "hidden @lg:inline";
|
|
121
|
+
return (
|
|
122
|
+
<div className="flex shrink-0 gap-1.5">
|
|
123
|
+
{full && (
|
|
124
|
+
<Button
|
|
125
|
+
variant="outline"
|
|
126
|
+
size="sm"
|
|
127
|
+
onClick={onFull}
|
|
128
|
+
title="Full screen"
|
|
129
|
+
>
|
|
130
|
+
<Maximize2 className="h-3.5 w-3.5" />
|
|
131
|
+
<span className={label}>Full screen</span>
|
|
132
|
+
</Button>
|
|
133
|
+
)}
|
|
134
|
+
{has("share") && (
|
|
135
|
+
<Button variant="outline" size="sm" onClick={share} title="Share">
|
|
136
|
+
<Share2 className="h-3.5 w-3.5" />
|
|
137
|
+
<span className={label}>Share</span>
|
|
138
|
+
</Button>
|
|
139
|
+
)}
|
|
140
|
+
{has("open") && (
|
|
141
|
+
<Button variant="outline" size="sm" asChild>
|
|
142
|
+
<a
|
|
143
|
+
href={href}
|
|
144
|
+
target="_blank"
|
|
145
|
+
rel="noopener noreferrer"
|
|
146
|
+
title="Open in new tab"
|
|
147
|
+
>
|
|
148
|
+
<ExternalLink className="h-3.5 w-3.5" />
|
|
149
|
+
<span className={label}>Open</span>
|
|
150
|
+
</a>
|
|
151
|
+
</Button>
|
|
152
|
+
)}
|
|
153
|
+
{formats.length > 0 && (
|
|
154
|
+
<DownloadMenu
|
|
155
|
+
formats={formats}
|
|
156
|
+
request={request}
|
|
157
|
+
title={title}
|
|
158
|
+
disabled={false}
|
|
159
|
+
trigger={(pending) => (
|
|
160
|
+
<Button variant="outline" size="sm" title="Download">
|
|
161
|
+
{pending ? (
|
|
162
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
163
|
+
) : (
|
|
164
|
+
<Download className="h-3.5 w-3.5" />
|
|
165
|
+
)}
|
|
166
|
+
<span className={label}>Download</span>
|
|
167
|
+
</Button>
|
|
168
|
+
)}
|
|
169
|
+
/>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The card: every action as an icon from md up; on a phone, Share and a
|
|
176
|
+
// More menu, so the title keeps its room.
|
|
177
|
+
const wide = "hidden md:inline-flex";
|
|
178
|
+
return (
|
|
179
|
+
<div className="flex shrink-0 items-center">
|
|
180
|
+
{full && (
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
onClick={onFull}
|
|
184
|
+
className={cn(ICON_BUTTON, wide)}
|
|
185
|
+
aria-label="Full screen"
|
|
186
|
+
title="Full screen"
|
|
187
|
+
>
|
|
188
|
+
<Maximize2 className="h-4 w-4" />
|
|
189
|
+
</button>
|
|
190
|
+
)}
|
|
191
|
+
{has("share") && (
|
|
192
|
+
<button
|
|
193
|
+
type="button"
|
|
194
|
+
onClick={share}
|
|
195
|
+
className={ICON_BUTTON}
|
|
196
|
+
aria-label="Share"
|
|
197
|
+
title="Share"
|
|
198
|
+
>
|
|
199
|
+
<Share2 className="h-4 w-4" />
|
|
200
|
+
</button>
|
|
201
|
+
)}
|
|
202
|
+
{has("open") && (
|
|
203
|
+
<a
|
|
204
|
+
href={href}
|
|
205
|
+
target="_blank"
|
|
206
|
+
rel="noopener noreferrer"
|
|
207
|
+
className={cn(ICON_BUTTON, wide)}
|
|
208
|
+
aria-label="Open in new tab"
|
|
209
|
+
title="Open in new tab"
|
|
210
|
+
>
|
|
211
|
+
<ExternalLink className="h-4 w-4" />
|
|
212
|
+
</a>
|
|
213
|
+
)}
|
|
214
|
+
{formats.length > 0 && (
|
|
215
|
+
<DownloadMenu
|
|
216
|
+
formats={formats}
|
|
217
|
+
request={request}
|
|
218
|
+
title={title}
|
|
219
|
+
disabled={false}
|
|
220
|
+
trigger={(pending) => (
|
|
221
|
+
<button
|
|
222
|
+
type="button"
|
|
223
|
+
className={cn(ICON_BUTTON, wide)}
|
|
224
|
+
aria-label="Download"
|
|
225
|
+
title="Download"
|
|
226
|
+
>
|
|
227
|
+
{pending ? (
|
|
228
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
229
|
+
) : (
|
|
230
|
+
<Download className="h-4 w-4" />
|
|
231
|
+
)}
|
|
232
|
+
</button>
|
|
233
|
+
)}
|
|
234
|
+
/>
|
|
235
|
+
)}
|
|
236
|
+
<More
|
|
237
|
+
request={request}
|
|
238
|
+
title={title}
|
|
239
|
+
formats={formats}
|
|
240
|
+
onFull={full ? onFull : undefined}
|
|
241
|
+
href={has("open") ? href : undefined}
|
|
242
|
+
>
|
|
243
|
+
{(pending) => (
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
className={cn(ICON_BUTTON, "md:hidden")}
|
|
247
|
+
aria-label="More"
|
|
248
|
+
>
|
|
249
|
+
{pending ? (
|
|
250
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
251
|
+
) : (
|
|
252
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
253
|
+
)}
|
|
254
|
+
</button>
|
|
255
|
+
)}
|
|
256
|
+
</More>
|
|
257
|
+
</div>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// The phone's overflow menu: full screen and open when they didn't fit, then
|
|
262
|
+
// each download format.
|
|
263
|
+
function More({
|
|
264
|
+
request,
|
|
265
|
+
title,
|
|
266
|
+
formats,
|
|
267
|
+
onFull,
|
|
268
|
+
href,
|
|
269
|
+
children,
|
|
270
|
+
}: {
|
|
271
|
+
request: PreviewRequest;
|
|
272
|
+
title: string;
|
|
273
|
+
formats: DownloadFormat[];
|
|
274
|
+
onFull?: () => void;
|
|
275
|
+
href?: string;
|
|
276
|
+
children: (pending: boolean) => ReactNode;
|
|
277
|
+
}) {
|
|
278
|
+
const { choose, pending } = useShareDownload(request, title);
|
|
279
|
+
if (!onFull && !href && !formats.length) return null;
|
|
280
|
+
return (
|
|
281
|
+
<DropdownMenu>
|
|
282
|
+
<DropdownMenuTrigger asChild>{children(pending)}</DropdownMenuTrigger>
|
|
283
|
+
<DropdownMenuContent align="end" className="min-w-52">
|
|
284
|
+
{onFull && (
|
|
285
|
+
<DropdownMenuItem className="min-h-11 gap-2" onSelect={onFull}>
|
|
286
|
+
<Maximize2 className="h-4 w-4" /> Full screen
|
|
287
|
+
</DropdownMenuItem>
|
|
288
|
+
)}
|
|
289
|
+
{href && (
|
|
290
|
+
<DropdownMenuItem className="min-h-11 gap-2" asChild>
|
|
291
|
+
<a href={href} target="_blank" rel="noopener noreferrer">
|
|
292
|
+
<ExternalLink className="h-4 w-4" /> Open in new tab
|
|
293
|
+
</a>
|
|
294
|
+
</DropdownMenuItem>
|
|
295
|
+
)}
|
|
296
|
+
{formats.map((f) => (
|
|
297
|
+
<DropdownMenuItem
|
|
298
|
+
key={f}
|
|
299
|
+
className="min-h-11 gap-2"
|
|
300
|
+
onSelect={() => void choose(f)}
|
|
301
|
+
>
|
|
302
|
+
<Download className="h-4 w-4" /> {FORMAT_LABEL[f]}
|
|
303
|
+
</DropdownMenuItem>
|
|
304
|
+
))}
|
|
305
|
+
</DropdownMenuContent>
|
|
306
|
+
</DropdownMenu>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { X } from "lucide-react";
|
|
5
|
+
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
|
6
|
+
import { ArtifactFrame } from "@/components/Chat/ArtifactFrame";
|
|
7
|
+
import { ICON_BUTTON } from "./ArtifactActions";
|
|
8
|
+
|
|
9
|
+
// A rendered page over the whole screen, its actions in the bar.
|
|
10
|
+
export function ArtifactFullscreen({
|
|
11
|
+
open,
|
|
12
|
+
onOpenChange,
|
|
13
|
+
artifactId,
|
|
14
|
+
src,
|
|
15
|
+
title,
|
|
16
|
+
actions,
|
|
17
|
+
}: {
|
|
18
|
+
open: boolean;
|
|
19
|
+
onOpenChange: (open: boolean) => void;
|
|
20
|
+
artifactId: string;
|
|
21
|
+
src?: string;
|
|
22
|
+
title: string;
|
|
23
|
+
actions?: ReactNode;
|
|
24
|
+
}) {
|
|
25
|
+
return (
|
|
26
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
27
|
+
<DialogContent
|
|
28
|
+
showCloseButton={false}
|
|
29
|
+
className="flex h-[100dvh] w-screen max-w-none flex-col gap-0 rounded-none p-0 sm:max-w-none"
|
|
30
|
+
>
|
|
31
|
+
<div className="flex items-center gap-2 py-1 pt-[max(0.25rem,env(safe-area-inset-top))] pr-1 pl-4">
|
|
32
|
+
<DialogTitle className="min-w-0 flex-1 truncate text-sm font-medium">
|
|
33
|
+
{title}
|
|
34
|
+
</DialogTitle>
|
|
35
|
+
{actions}
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
onClick={() => onOpenChange(false)}
|
|
39
|
+
className={ICON_BUTTON}
|
|
40
|
+
aria-label="Close"
|
|
41
|
+
>
|
|
42
|
+
<X className="h-4 w-4" />
|
|
43
|
+
</button>
|
|
44
|
+
</div>
|
|
45
|
+
<div className="min-h-0 flex-1 pb-[env(safe-area-inset-bottom)]">
|
|
46
|
+
{open && (
|
|
47
|
+
<ArtifactFrame
|
|
48
|
+
artifactId={artifactId}
|
|
49
|
+
src={src}
|
|
50
|
+
title={title}
|
|
51
|
+
fill
|
|
52
|
+
/>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
</DialogContent>
|
|
56
|
+
</Dialog>
|
|
57
|
+
);
|
|
58
|
+
}
|