@tangle-network/ui 11.25.0 → 11.27.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.
- package/CHANGELOG.md +12 -0
- package/README.md +22 -0
- package/dist/editor/editor-peers.js +1 -21
- package/dist/files/file-card.d.ts +30 -0
- package/dist/files/file-card.js +162 -0
- package/dist/files/file-list.d.ts +25 -0
- package/dist/files/file-list.js +88 -0
- package/dist/files/file-preview.js +16 -62
- package/dist/files/pdf-loader.d.ts +22 -0
- package/dist/files/pdf-loader.js +82 -0
- package/dist/files/pdf-viewer.d.ts +32 -0
- package/dist/files/pdf-viewer.js +567 -0
- package/dist/files.d.ts +5 -1
- package/dist/files.js +5 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.js +5 -1
- package/dist/lib/optional-peer.js +29 -0
- package/dist/markdown/markdown.d.ts +3 -2
- package/dist/markdown/markdown.js +70 -4
- package/dist/primitives.d.ts +2 -2
- package/package.json +7 -2
- package/src/editor/editor-peers.ts +3 -25
- package/src/files/file-artifact-pane.test.tsx +12 -2
- package/src/files/file-card.test.tsx +62 -0
- package/src/files/file-card.tsx +210 -0
- package/src/files/file-list.stories.tsx +121 -0
- package/src/files/file-list.test.tsx +81 -0
- package/src/files/file-list.tsx +167 -0
- package/src/files/file-preview.tsx +25 -94
- package/src/files/index.ts +9 -0
- package/src/files/pdf-loader.test.ts +130 -0
- package/src/files/pdf-loader.ts +169 -0
- package/src/files/pdf-viewer.stories.tsx +81 -0
- package/src/files/pdf-viewer.test.tsx +245 -0
- package/src/files/pdf-viewer.tsx +677 -0
- package/src/files/pdfjs-worker.d.ts +3 -0
- package/src/lib/optional-peer.ts +30 -0
- package/src/markdown/markdown.test.tsx +32 -3
- package/src/markdown/markdown.tsx +85 -4
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Button } from "../primitives/button";
|
|
4
|
+
import { FileCard } from "./file-card";
|
|
5
|
+
import { FileList, type FileListItem } from "./file-list";
|
|
6
|
+
|
|
7
|
+
const CASE_FILES: FileListItem[] = [
|
|
8
|
+
{
|
|
9
|
+
id: "lease",
|
|
10
|
+
filename: "Commercial lease — 418 Harbor Street.pdf",
|
|
11
|
+
size: 2_516_582,
|
|
12
|
+
date: "2026-09-28T16:20:00Z",
|
|
13
|
+
uploader: { name: "Dana Ruiz", role: "Client" },
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: "notice",
|
|
17
|
+
filename: "Notice of lease termination.pdf",
|
|
18
|
+
size: 75_955,
|
|
19
|
+
date: "2026-10-06T09:05:00Z",
|
|
20
|
+
uploader: { name: "Priya Adeyemi", role: "Attorney" },
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "photos",
|
|
24
|
+
filename: "Rear storage room, water damage.jpg",
|
|
25
|
+
mimeType: "image/jpeg",
|
|
26
|
+
size: 3_145_728,
|
|
27
|
+
date: "2026-08-12T18:42:00Z",
|
|
28
|
+
uploader: { name: "Dana Ruiz", role: "Client" },
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "ledger",
|
|
32
|
+
filename: "Rent ledger 2023–2026.xlsx",
|
|
33
|
+
size: 48_640,
|
|
34
|
+
date: "2026-09-30T11:00:00Z",
|
|
35
|
+
uploader: { name: "Coastal Property Holdings" },
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "estimate",
|
|
39
|
+
filename: "Contractor estimate.docx",
|
|
40
|
+
size: 182_000,
|
|
41
|
+
date: "2026-08-20T14:10:00Z",
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
function CaseDocuments() {
|
|
46
|
+
const [files, setFiles] = useState(CASE_FILES);
|
|
47
|
+
const [openId, setOpenId] = useState("notice");
|
|
48
|
+
return (
|
|
49
|
+
<FileList
|
|
50
|
+
aria-label="Case documents"
|
|
51
|
+
files={files}
|
|
52
|
+
activeId={openId}
|
|
53
|
+
onOpen={(file) => setOpenId(file.id)}
|
|
54
|
+
onDownload={() => {}}
|
|
55
|
+
onDelete={(file) => setFiles((current) => current.filter((item) => item.id !== file.id))}
|
|
56
|
+
empty="No documents yet. Upload the lease to get started."
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const meta: Meta<typeof FileList> = {
|
|
62
|
+
title: "Files/FileList",
|
|
63
|
+
component: FileList,
|
|
64
|
+
parameters: { layout: "fullscreen" },
|
|
65
|
+
decorators: [
|
|
66
|
+
(Story) => (
|
|
67
|
+
<div className="min-h-[100dvh] bg-background p-4 text-foreground">
|
|
68
|
+
<div className="mx-auto max-w-2xl">
|
|
69
|
+
<Story />
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
),
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export default meta;
|
|
77
|
+
type Story = StoryObj<typeof FileList>;
|
|
78
|
+
|
|
79
|
+
export const Documents: Story = {
|
|
80
|
+
name: "Case documents",
|
|
81
|
+
render: () => <CaseDocuments />,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const DocumentsLight: Story = {
|
|
85
|
+
name: "Case documents (light)",
|
|
86
|
+
globals: { theme: "light" },
|
|
87
|
+
render: () => <CaseDocuments />,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const ReadOnly: Story = {
|
|
91
|
+
name: "Read-only list",
|
|
92
|
+
render: () => <FileList aria-label="Shared with you" files={CASE_FILES.slice(0, 3)} />,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const Empty: Story = {
|
|
96
|
+
render: () => <FileList files={[]} empty="No documents yet. Upload the lease to get started." />,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const Card: Story = {
|
|
100
|
+
name: "FileCard",
|
|
101
|
+
render: () => (
|
|
102
|
+
<FileCard
|
|
103
|
+
filename="Commercial lease — 418 Harbor Street.pdf"
|
|
104
|
+
size={2_516_582}
|
|
105
|
+
date="2026-09-28T16:20:00Z"
|
|
106
|
+
uploader={{ name: "Dana Ruiz", role: "Client" }}
|
|
107
|
+
onDownload={() => {}}
|
|
108
|
+
actions={
|
|
109
|
+
<Button type="button" size="sm">
|
|
110
|
+
Open
|
|
111
|
+
</Button>
|
|
112
|
+
}
|
|
113
|
+
/>
|
|
114
|
+
),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const CardLight: Story = {
|
|
118
|
+
name: "FileCard (light)",
|
|
119
|
+
globals: { theme: "light" },
|
|
120
|
+
render: Card.render,
|
|
121
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { fireEvent, render, screen, within } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { FileList, type FileListItem } from "./file-list";
|
|
4
|
+
|
|
5
|
+
interface CaseDocument extends FileListItem {
|
|
6
|
+
matterId: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const FILES: CaseDocument[] = [
|
|
10
|
+
{
|
|
11
|
+
id: "lease",
|
|
12
|
+
matterId: "m-1",
|
|
13
|
+
filename: "Commercial lease.pdf",
|
|
14
|
+
size: 482_000,
|
|
15
|
+
date: "2026-10-01T12:00:00Z",
|
|
16
|
+
uploader: { name: "Dana Ruiz", role: "Client" },
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: "photos",
|
|
20
|
+
matterId: "m-1",
|
|
21
|
+
filename: "Water damage.jpg",
|
|
22
|
+
mimeType: "image/jpeg",
|
|
23
|
+
size: 3_100_000,
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
describe("FileList", () => {
|
|
28
|
+
it("shows each file's kind, size and uploader", () => {
|
|
29
|
+
render(<FileList files={FILES} aria-label="Case documents" />);
|
|
30
|
+
const rows = within(screen.getByRole("list", { name: "Case documents" })).getAllByRole(
|
|
31
|
+
"listitem",
|
|
32
|
+
);
|
|
33
|
+
expect(rows).toHaveLength(2);
|
|
34
|
+
expect(rows[0]).toHaveTextContent("Commercial lease.pdf");
|
|
35
|
+
// A dot between facts on screen, a comma for a screen reader.
|
|
36
|
+
expect(rows[0]).toHaveTextContent(/PDF · , 471 KB · , .+ · , Dana Ruiz \(Client\)/);
|
|
37
|
+
expect(rows[1]).toHaveTextContent("Image · , 3.0 MB");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("opens a file from its row and hands back the host's own record", () => {
|
|
41
|
+
const onOpen = vi.fn();
|
|
42
|
+
render(<FileList files={FILES} onOpen={onOpen} activeId="photos" />);
|
|
43
|
+
|
|
44
|
+
fireEvent.click(screen.getByRole("button", { name: /Commercial lease\.pdf/ }));
|
|
45
|
+
expect(onOpen).toHaveBeenCalledWith(FILES[0]);
|
|
46
|
+
expect(onOpen.mock.calls[0][0].matterId).toBe("m-1");
|
|
47
|
+
|
|
48
|
+
expect(screen.getByRole("button", { name: /Water damage\.jpg/ })).toHaveAttribute(
|
|
49
|
+
"aria-current",
|
|
50
|
+
"true",
|
|
51
|
+
);
|
|
52
|
+
expect(screen.getByRole("button", { name: /Commercial lease\.pdf/ })).not.toHaveAttribute(
|
|
53
|
+
"aria-current",
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("downloads and deletes through per-file buttons", () => {
|
|
58
|
+
const onDownload = vi.fn();
|
|
59
|
+
const onDelete = vi.fn();
|
|
60
|
+
render(<FileList files={FILES} onDownload={onDownload} onDelete={onDelete} />);
|
|
61
|
+
|
|
62
|
+
fireEvent.click(screen.getByRole("button", { name: "Download Water damage.jpg" }));
|
|
63
|
+
expect(onDownload).toHaveBeenCalledWith(FILES[1]);
|
|
64
|
+
fireEvent.click(screen.getByRole("button", { name: "Delete Commercial lease.pdf" }));
|
|
65
|
+
expect(onDelete).toHaveBeenCalledWith(FILES[0]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("renders no controls the host did not ask for", () => {
|
|
69
|
+
render(<FileList files={FILES} />);
|
|
70
|
+
expect(screen.queryByRole("button")).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("shows the empty message in place of the list", () => {
|
|
74
|
+
const { rerender } = render(<FileList files={[]} />);
|
|
75
|
+
expect(screen.queryByRole("list")).toBeNull();
|
|
76
|
+
expect(screen.getByText("No files yet.")).toBeInTheDocument();
|
|
77
|
+
|
|
78
|
+
rerender(<FileList files={[]} empty="Upload the lease to start." />);
|
|
79
|
+
expect(screen.getByText("Upload the lease to start.")).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileList — a flat list of documents: each row names the file and its kind,
|
|
3
|
+
* size, date and uploader; selecting the row opens it, and the row's own
|
|
4
|
+
* buttons download or delete it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as React from "react";
|
|
8
|
+
import { Download, Trash2 } from "lucide-react";
|
|
9
|
+
import { focusRingInset } from "../lib/focus";
|
|
10
|
+
import { cn } from "../lib/utils";
|
|
11
|
+
import { Button } from "../primitives/button";
|
|
12
|
+
import {
|
|
13
|
+
FactSeparator,
|
|
14
|
+
FileFacts,
|
|
15
|
+
FileKindTile,
|
|
16
|
+
uploaderLabel,
|
|
17
|
+
type FileDetails,
|
|
18
|
+
} from "./file-card";
|
|
19
|
+
|
|
20
|
+
export interface FileListItem extends FileDetails {
|
|
21
|
+
/** Stable key, and what `activeId` matches. */
|
|
22
|
+
id: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface FileListProps<T extends FileListItem = FileListItem>
|
|
26
|
+
extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
|
|
27
|
+
files: readonly T[];
|
|
28
|
+
/** Opens a file, usually in a preview. Makes each row a button. */
|
|
29
|
+
onOpen?: (file: T) => void;
|
|
30
|
+
/** Adds a Download button to each row. */
|
|
31
|
+
onDownload?: (file: T) => void;
|
|
32
|
+
/**
|
|
33
|
+
* Adds a Delete button to each row. It calls this at once, so a host that
|
|
34
|
+
* wants a confirmation asks for it here.
|
|
35
|
+
*/
|
|
36
|
+
onDelete?: (file: T) => void;
|
|
37
|
+
/** The open file. Its row is marked as the current one. */
|
|
38
|
+
activeId?: string;
|
|
39
|
+
/** Shown in place of the list when `files` is empty. */
|
|
40
|
+
empty?: React.ReactNode;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function FileList<T extends FileListItem>({
|
|
44
|
+
files,
|
|
45
|
+
onOpen,
|
|
46
|
+
onDownload,
|
|
47
|
+
onDelete,
|
|
48
|
+
activeId,
|
|
49
|
+
empty = "No files yet.",
|
|
50
|
+
className,
|
|
51
|
+
...props
|
|
52
|
+
}: FileListProps<T>) {
|
|
53
|
+
if (files.length === 0) {
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
className={cn(
|
|
57
|
+
"rounded-[var(--radius-md)] border border-border bg-card px-4 py-10 text-center text-sm text-muted-foreground",
|
|
58
|
+
className,
|
|
59
|
+
)}
|
|
60
|
+
{...props}
|
|
61
|
+
>
|
|
62
|
+
{empty}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<ul
|
|
69
|
+
className={cn(
|
|
70
|
+
"divide-y divide-border overflow-hidden rounded-[var(--radius-md)] border border-border bg-card",
|
|
71
|
+
className,
|
|
72
|
+
)}
|
|
73
|
+
{...props}
|
|
74
|
+
>
|
|
75
|
+
{files.map((file) => {
|
|
76
|
+
const active = file.id === activeId;
|
|
77
|
+
const body = (
|
|
78
|
+
<>
|
|
79
|
+
<FileKindTile filename={file.filename} mimeType={file.mimeType} />
|
|
80
|
+
{/* On a phone the name may take two lines and the uploader takes
|
|
81
|
+
its own, since one line of facts cannot hold who sent the file. */}
|
|
82
|
+
<span className="grid min-w-0 flex-1">
|
|
83
|
+
<span className="line-clamp-2 text-sm font-medium text-foreground [overflow-wrap:anywhere] sm:line-clamp-1">
|
|
84
|
+
{file.filename}
|
|
85
|
+
</span>
|
|
86
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
87
|
+
<FileFacts
|
|
88
|
+
filename={file.filename}
|
|
89
|
+
mimeType={file.mimeType}
|
|
90
|
+
size={file.size}
|
|
91
|
+
date={file.date}
|
|
92
|
+
showKind
|
|
93
|
+
/>
|
|
94
|
+
{file.uploader && (
|
|
95
|
+
<span className="hidden sm:inline">
|
|
96
|
+
<FactSeparator />
|
|
97
|
+
{uploaderLabel(file.uploader)}
|
|
98
|
+
</span>
|
|
99
|
+
)}
|
|
100
|
+
</span>
|
|
101
|
+
{file.uploader && (
|
|
102
|
+
<span className="truncate text-xs text-muted-foreground sm:hidden">
|
|
103
|
+
{uploaderLabel(file.uploader)}
|
|
104
|
+
</span>
|
|
105
|
+
)}
|
|
106
|
+
</span>
|
|
107
|
+
</>
|
|
108
|
+
);
|
|
109
|
+
return (
|
|
110
|
+
<li
|
|
111
|
+
key={file.id}
|
|
112
|
+
className={cn(
|
|
113
|
+
"flex items-center gap-1 pr-2",
|
|
114
|
+
active ? "bg-primary/10" : onOpen && "hover:bg-[var(--bg-hover)]",
|
|
115
|
+
)}
|
|
116
|
+
>
|
|
117
|
+
{onOpen ? (
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
onClick={() => onOpen(file)}
|
|
121
|
+
aria-current={active ? "true" : undefined}
|
|
122
|
+
className={cn(
|
|
123
|
+
"flex min-w-0 flex-1 items-center gap-3 py-2.5 pl-3 text-left",
|
|
124
|
+
focusRingInset,
|
|
125
|
+
)}
|
|
126
|
+
>
|
|
127
|
+
{body}
|
|
128
|
+
</button>
|
|
129
|
+
) : (
|
|
130
|
+
<div
|
|
131
|
+
aria-current={active ? "true" : undefined}
|
|
132
|
+
className="flex min-w-0 flex-1 items-center gap-3 py-2.5 pl-3"
|
|
133
|
+
>
|
|
134
|
+
{body}
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
{onDownload && (
|
|
138
|
+
<Button
|
|
139
|
+
type="button"
|
|
140
|
+
variant="ghost"
|
|
141
|
+
size="icon"
|
|
142
|
+
aria-label={`Download ${file.filename}`}
|
|
143
|
+
title="Download"
|
|
144
|
+
onClick={() => onDownload(file)}
|
|
145
|
+
>
|
|
146
|
+
<Download />
|
|
147
|
+
</Button>
|
|
148
|
+
)}
|
|
149
|
+
{onDelete && (
|
|
150
|
+
<Button
|
|
151
|
+
type="button"
|
|
152
|
+
variant="ghost"
|
|
153
|
+
size="icon"
|
|
154
|
+
aria-label={`Delete ${file.filename}`}
|
|
155
|
+
title="Delete"
|
|
156
|
+
className="hover:text-[var(--surface-danger-text)]"
|
|
157
|
+
onClick={() => onDelete(file)}
|
|
158
|
+
>
|
|
159
|
+
<Trash2 />
|
|
160
|
+
</Button>
|
|
161
|
+
)}
|
|
162
|
+
</li>
|
|
163
|
+
);
|
|
164
|
+
})}
|
|
165
|
+
</ul>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* FilePreview — universal file renderer.
|
|
3
3
|
*
|
|
4
4
|
* Renders any file type:
|
|
5
|
-
* - PDF:
|
|
5
|
+
* - PDF: PdfViewer's rendered pages at full pane height
|
|
6
6
|
* - Images: fit to the pane on a checker ground; click toggles natural size
|
|
7
7
|
* - Video / audio: native players
|
|
8
8
|
* - CSV: sticky-header table, capped at CSV_PREVIEW_ROW_LIMIT rows
|
|
@@ -14,21 +14,13 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { useState } from "react";
|
|
17
|
-
import {
|
|
18
|
-
Download,
|
|
19
|
-
FileSpreadsheet,
|
|
20
|
-
FileText,
|
|
21
|
-
Music,
|
|
22
|
-
X,
|
|
23
|
-
type LucideIcon,
|
|
24
|
-
} from "lucide-react";
|
|
17
|
+
import { Download, Music, X } from "lucide-react";
|
|
25
18
|
import type { UrlTransform } from "react-markdown";
|
|
26
19
|
import { focusRingInset } from "../lib/focus";
|
|
27
20
|
import { cn } from "../lib/utils";
|
|
28
|
-
import { Button } from "../primitives/button";
|
|
29
21
|
import { Markdown } from "../markdown/markdown";
|
|
30
22
|
import { CodeBlock, CopyButton } from "../markdown/code-block";
|
|
31
|
-
import {
|
|
23
|
+
import { FileCard } from "./file-card";
|
|
32
24
|
import {
|
|
33
25
|
fileExtension,
|
|
34
26
|
getCodeLanguage,
|
|
@@ -36,6 +28,7 @@ import {
|
|
|
36
28
|
resolveFilePreviewKind,
|
|
37
29
|
type FilePreviewKind,
|
|
38
30
|
} from "./file-format";
|
|
31
|
+
import { PdfViewer } from "./pdf-viewer";
|
|
39
32
|
|
|
40
33
|
export interface FilePreviewProps {
|
|
41
34
|
filename: string;
|
|
@@ -239,38 +232,6 @@ function ImagePreview({ src, filename }: { src: string; filename: string }) {
|
|
|
239
232
|
);
|
|
240
233
|
}
|
|
241
234
|
|
|
242
|
-
function PdfPreview({
|
|
243
|
-
blobUrl,
|
|
244
|
-
filename,
|
|
245
|
-
size,
|
|
246
|
-
onDownload,
|
|
247
|
-
}: {
|
|
248
|
-
blobUrl: string;
|
|
249
|
-
filename: string;
|
|
250
|
-
size?: number;
|
|
251
|
-
onDownload?: () => void;
|
|
252
|
-
}) {
|
|
253
|
-
// <object> lets a browser without an inline PDF viewer render the fallback
|
|
254
|
-
// card instead of a blank frame. The viewer draws its own chrome, so the
|
|
255
|
-
// object has no border; the card brings its own and fills the same height.
|
|
256
|
-
return (
|
|
257
|
-
<object
|
|
258
|
-
data={blobUrl}
|
|
259
|
-
type="application/pdf"
|
|
260
|
-
title={filename}
|
|
261
|
-
className="min-h-[24rem] w-full flex-1 rounded-[var(--radius-md)]"
|
|
262
|
-
>
|
|
263
|
-
<DownloadCard
|
|
264
|
-
filename={filename}
|
|
265
|
-
size={size}
|
|
266
|
-
sentence="This browser does not show PDFs inline."
|
|
267
|
-
onDownload={onDownload}
|
|
268
|
-
className="h-full"
|
|
269
|
-
/>
|
|
270
|
-
</object>
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
235
|
function VideoPreview({ src, filename }: { src: string; filename: string }) {
|
|
275
236
|
return (
|
|
276
237
|
<div className="flex min-h-[12rem] flex-1 items-center justify-center overflow-hidden rounded-[var(--radius-md)] border border-border bg-background">
|
|
@@ -318,52 +279,10 @@ function MarkdownPreview({
|
|
|
318
279
|
);
|
|
319
280
|
}
|
|
320
281
|
|
|
321
|
-
/**
|
|
322
|
-
* One muted sentence plus the filename, its size when known, and a Download
|
|
323
|
-
* button when the host wires `onDownload`. Serves spreadsheets, binaries, and
|
|
324
|
-
* every kind whose source (blob or text) is missing.
|
|
325
|
-
*/
|
|
326
|
-
function DownloadCard({
|
|
327
|
-
filename,
|
|
328
|
-
size,
|
|
329
|
-
sentence,
|
|
330
|
-
icon: Icon = FileText,
|
|
331
|
-
onDownload,
|
|
332
|
-
className,
|
|
333
|
-
}: {
|
|
334
|
-
filename: string;
|
|
335
|
-
size?: number;
|
|
336
|
-
sentence: string;
|
|
337
|
-
icon?: LucideIcon;
|
|
338
|
-
onDownload?: () => void;
|
|
339
|
-
className?: string;
|
|
340
|
-
}) {
|
|
341
|
-
return (
|
|
342
|
-
<div
|
|
343
|
-
className={cn(
|
|
344
|
-
"flex flex-col items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border bg-background px-6 py-16 text-center",
|
|
345
|
-
className,
|
|
346
|
-
)}
|
|
347
|
-
>
|
|
348
|
-
<Icon className="mb-3 h-12 w-12 text-muted-foreground opacity-40" />
|
|
349
|
-
<p className="max-w-full truncate text-sm font-medium text-foreground">{filename}</p>
|
|
350
|
-
{size !== undefined && (
|
|
351
|
-
<p className="mt-1 text-xs text-muted-foreground">{formatBytes(size)}</p>
|
|
352
|
-
)}
|
|
353
|
-
<p className="mt-3 max-w-md text-sm text-muted-foreground">{sentence}</p>
|
|
354
|
-
{onDownload && (
|
|
355
|
-
<Button type="button" variant="outline" size="sm" className="mt-5" onClick={onDownload}>
|
|
356
|
-
<Download />
|
|
357
|
-
Download
|
|
358
|
-
</Button>
|
|
359
|
-
)}
|
|
360
|
-
</div>
|
|
361
|
-
);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
282
|
function PreviewBody({
|
|
365
283
|
kind,
|
|
366
284
|
filename,
|
|
285
|
+
mimeType,
|
|
367
286
|
content,
|
|
368
287
|
blobUrl,
|
|
369
288
|
size,
|
|
@@ -372,6 +291,7 @@ function PreviewBody({
|
|
|
372
291
|
}: {
|
|
373
292
|
kind: FilePreviewKind;
|
|
374
293
|
filename: string;
|
|
294
|
+
mimeType?: string;
|
|
375
295
|
content?: string;
|
|
376
296
|
blobUrl?: string;
|
|
377
297
|
size?: number;
|
|
@@ -379,12 +299,14 @@ function PreviewBody({
|
|
|
379
299
|
onDownload?: () => void;
|
|
380
300
|
}) {
|
|
381
301
|
const hasText = typeof content === "string";
|
|
382
|
-
|
|
383
|
-
|
|
302
|
+
// The card takes its icon from the kind, so a spreadsheet or a recording
|
|
303
|
+
// reads as one even when it cannot be shown.
|
|
304
|
+
const card = (sentence: string) => (
|
|
305
|
+
<FileCard
|
|
384
306
|
filename={filename}
|
|
307
|
+
mimeType={mimeType}
|
|
385
308
|
size={size}
|
|
386
|
-
|
|
387
|
-
icon={icon}
|
|
309
|
+
description={sentence}
|
|
388
310
|
onDownload={onDownload}
|
|
389
311
|
/>
|
|
390
312
|
);
|
|
@@ -392,7 +314,15 @@ function PreviewBody({
|
|
|
392
314
|
switch (kind) {
|
|
393
315
|
case "pdf":
|
|
394
316
|
if (blobUrl) {
|
|
395
|
-
return
|
|
317
|
+
return (
|
|
318
|
+
<PdfViewer
|
|
319
|
+
src={blobUrl}
|
|
320
|
+
filename={filename}
|
|
321
|
+
size={size}
|
|
322
|
+
onDownload={onDownload}
|
|
323
|
+
className="min-h-[24rem] flex-1"
|
|
324
|
+
/>
|
|
325
|
+
);
|
|
396
326
|
}
|
|
397
327
|
return card(NEEDS_DOWNLOAD_LINK);
|
|
398
328
|
case "image":
|
|
@@ -405,10 +335,10 @@ function PreviewBody({
|
|
|
405
335
|
return card(NEEDS_DOWNLOAD_LINK);
|
|
406
336
|
case "audio":
|
|
407
337
|
if (blobUrl) return <AudioPreview src={blobUrl} filename={filename} />;
|
|
408
|
-
return card(NEEDS_DOWNLOAD_LINK
|
|
338
|
+
return card(NEEDS_DOWNLOAD_LINK);
|
|
409
339
|
case "csv":
|
|
410
340
|
if (hasText) return <CsvPreview content={content} />;
|
|
411
|
-
return card(NO_INLINE_CONTENT
|
|
341
|
+
return card(NO_INLINE_CONTENT);
|
|
412
342
|
case "code":
|
|
413
343
|
case "json":
|
|
414
344
|
case "yaml":
|
|
@@ -421,7 +351,7 @@ function PreviewBody({
|
|
|
421
351
|
if (hasText) return <TextPreview content={content} />;
|
|
422
352
|
return card(NO_INLINE_CONTENT);
|
|
423
353
|
case "spreadsheet":
|
|
424
|
-
return card("Download to open this workbook in a spreadsheet app."
|
|
354
|
+
return card("Download to open this workbook in a spreadsheet app.");
|
|
425
355
|
case "binary":
|
|
426
356
|
if (hasText) return <TextPreview content={content} />;
|
|
427
357
|
return card(blobUrl ? NO_INLINE_PREVIEW : NEEDS_DOWNLOAD_LINK);
|
|
@@ -482,6 +412,7 @@ export function FilePreview({
|
|
|
482
412
|
<PreviewBody
|
|
483
413
|
kind={kind}
|
|
484
414
|
filename={filename}
|
|
415
|
+
mimeType={mimeType}
|
|
485
416
|
content={content}
|
|
486
417
|
blobUrl={blobUrl}
|
|
487
418
|
size={size}
|
package/src/files/index.ts
CHANGED
|
@@ -18,6 +18,15 @@ export {
|
|
|
18
18
|
parseCsv,
|
|
19
19
|
type FilePreviewProps,
|
|
20
20
|
} from "./file-preview";
|
|
21
|
+
export { PdfViewer, type PdfViewerProps } from "./pdf-viewer";
|
|
22
|
+
export { configurePdfViewer, type PdfViewerConfig } from "./pdf-loader";
|
|
23
|
+
export {
|
|
24
|
+
FileCard,
|
|
25
|
+
type FileCardProps,
|
|
26
|
+
type FileDetails,
|
|
27
|
+
type FileUploader,
|
|
28
|
+
} from "./file-card";
|
|
29
|
+
export { FileList, type FileListProps, type FileListItem } from "./file-list";
|
|
21
30
|
export { FileTabs, type FileTabsProps, type FileTabData } from "./file-tabs";
|
|
22
31
|
export { FileArtifactPane, type FileArtifactPaneProps } from "./file-artifact-pane";
|
|
23
32
|
export {
|