@tangle-network/ui 11.24.0 → 11.26.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +22 -0
  3. package/dist/chat/agent-timeline.d.ts +13 -1
  4. package/dist/chat/agent-timeline.js +25 -4
  5. package/dist/chat/chat-message.d.ts +13 -1
  6. package/dist/chat/chat-message.js +9 -5
  7. package/dist/chat/message-author.d.ts +24 -0
  8. package/dist/chat/message-author.js +53 -0
  9. package/dist/chat/user-message.d.ts +12 -2
  10. package/dist/chat/user-message.js +52 -16
  11. package/dist/chat.d.ts +3 -1
  12. package/dist/chat.js +2 -1
  13. package/dist/editor/editor-peers.js +1 -21
  14. package/dist/files/file-card.d.ts +30 -0
  15. package/dist/files/file-card.js +162 -0
  16. package/dist/files/file-list.d.ts +25 -0
  17. package/dist/files/file-list.js +88 -0
  18. package/dist/files/file-preview.js +16 -62
  19. package/dist/files/pdf-loader.d.ts +22 -0
  20. package/dist/files/pdf-loader.js +82 -0
  21. package/dist/files/pdf-viewer.d.ts +32 -0
  22. package/dist/files/pdf-viewer.js +567 -0
  23. package/dist/files.d.ts +5 -1
  24. package/dist/files.js +5 -1
  25. package/dist/index.d.ts +9 -4
  26. package/dist/index.js +6 -1
  27. package/dist/lib/optional-peer.js +29 -0
  28. package/dist/primitives.d.ts +2 -2
  29. package/dist/types/message.d.ts +19 -0
  30. package/dist/types.d.ts +2 -2
  31. package/package.json +7 -2
  32. package/src/chat/agent-timeline.tsx +33 -3
  33. package/src/chat/chat-message.tsx +37 -10
  34. package/src/chat/index.ts +2 -0
  35. package/src/chat/message-author.stories.tsx +170 -0
  36. package/src/chat/message-author.test.tsx +189 -0
  37. package/src/chat/message-author.tsx +52 -0
  38. package/src/chat/user-message.tsx +63 -8
  39. package/src/editor/editor-peers.ts +3 -25
  40. package/src/files/file-artifact-pane.test.tsx +12 -2
  41. package/src/files/file-card.test.tsx +62 -0
  42. package/src/files/file-card.tsx +210 -0
  43. package/src/files/file-list.stories.tsx +121 -0
  44. package/src/files/file-list.test.tsx +81 -0
  45. package/src/files/file-list.tsx +167 -0
  46. package/src/files/file-preview.tsx +25 -94
  47. package/src/files/index.ts +9 -0
  48. package/src/files/pdf-loader.test.ts +130 -0
  49. package/src/files/pdf-loader.ts +169 -0
  50. package/src/files/pdf-viewer.stories.tsx +81 -0
  51. package/src/files/pdf-viewer.test.tsx +245 -0
  52. package/src/files/pdf-viewer.tsx +677 -0
  53. package/src/files/pdfjs-worker.d.ts +3 -0
  54. package/src/lib/optional-peer.ts +30 -0
  55. package/src/markdown/code-block.test.tsx +2 -1
  56. package/src/types/message.ts +20 -0
@@ -0,0 +1,210 @@
1
+ /**
2
+ * FileCard — one file, described: its kind, name, size, date and uploader,
3
+ * with Download and any actions the host adds. FilePreview shows it for a file
4
+ * it cannot render inline; a host can show it anywhere a file stands on its own.
5
+ */
6
+
7
+ import * as React from "react";
8
+ import {
9
+ Download,
10
+ File,
11
+ FileAudio,
12
+ FileCode,
13
+ FileImage,
14
+ FileJson,
15
+ FileSpreadsheet,
16
+ FileText,
17
+ FileVideo,
18
+ type LucideIcon,
19
+ } from "lucide-react";
20
+ import { cn } from "../lib/utils";
21
+ import { Button } from "../primitives/button";
22
+ import { IconTile, type IconTileProps } from "../primitives/icon-tile";
23
+ import type { CategoryTone } from "../primitives/tone";
24
+ import { formatBytes } from "../utils/format";
25
+ import { getFormatLabel, resolveFilePreviewKind, type FilePreviewKind } from "./file-format";
26
+
27
+ /** Who added a file, as a reader of the list should see them. */
28
+ export interface FileUploader {
29
+ name: string;
30
+ /** Their relationship to the work, e.g. "Client" or "Attorney". */
31
+ role?: string;
32
+ }
33
+
34
+ /** What FileCard and FileList show about one file. */
35
+ export interface FileDetails {
36
+ filename: string;
37
+ /** Wins over the extension when choosing the kind, as in FilePreview. */
38
+ mimeType?: string;
39
+ /** Size in bytes. */
40
+ size?: number;
41
+ /** When the file was added or last changed. */
42
+ date?: Date | string | number;
43
+ uploader?: FileUploader;
44
+ }
45
+
46
+ // File kinds are categories, so each takes a categorical tone and never a
47
+ // status colour: a PDF is not an error. See "Color Roles" in the brand guide.
48
+ const KIND_ICON: Record<FilePreviewKind, { icon: LucideIcon; tone: CategoryTone }> = {
49
+ pdf: { icon: FileText, tone: "orange" },
50
+ image: { icon: FileImage, tone: "pink" },
51
+ video: { icon: FileVideo, tone: "violet" },
52
+ audio: { icon: FileAudio, tone: "cyan" },
53
+ csv: { icon: FileSpreadsheet, tone: "lime" },
54
+ spreadsheet: { icon: FileSpreadsheet, tone: "lime" },
55
+ code: { icon: FileCode, tone: "blue" },
56
+ json: { icon: FileJson, tone: "blue" },
57
+ yaml: { icon: FileCode, tone: "blue" },
58
+ markdown: { icon: FileText, tone: "teal" },
59
+ text: { icon: FileText, tone: "teal" },
60
+ binary: { icon: File, tone: "brown" },
61
+ };
62
+
63
+ /** The kind's icon on its category tone. Decorative: the filename names the file. */
64
+ export function FileKindTile({
65
+ filename,
66
+ mimeType,
67
+ size = "md",
68
+ className,
69
+ }: {
70
+ filename: string;
71
+ mimeType?: string;
72
+ size?: IconTileProps["size"];
73
+ className?: string;
74
+ }) {
75
+ const { icon: Icon, tone } = KIND_ICON[resolveFilePreviewKind(filename, mimeType)];
76
+ return <IconTile tone={tone} size={size} icon={<Icon />} className={className} />;
77
+ }
78
+
79
+ function toDate(value: FileDetails["date"]): Date | undefined {
80
+ if (value === undefined) return undefined;
81
+ const date = value instanceof Date ? value : new Date(value);
82
+ return Number.isNaN(date.getTime()) ? undefined : date;
83
+ }
84
+
85
+ const DATE_FORMAT = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" });
86
+
87
+ /**
88
+ * A dot between facts for the eye, a comma for a screen reader, which would
89
+ * otherwise read the facts as one run-on word or announce "middle dot".
90
+ */
91
+ export function FactSeparator() {
92
+ return (
93
+ <>
94
+ <span aria-hidden="true"> · </span>
95
+ <span className="sr-only">, </span>
96
+ </>
97
+ );
98
+ }
99
+
100
+ /**
101
+ * The kind, size and date as one muted line, each part only when known.
102
+ * The date follows the reader's locale, which a server render may not share,
103
+ * so the element tolerates a hydration difference.
104
+ */
105
+ export function FileFacts({
106
+ filename,
107
+ mimeType,
108
+ size,
109
+ date,
110
+ showKind = false,
111
+ className,
112
+ }: Pick<FileDetails, "filename" | "mimeType" | "size" | "date"> & {
113
+ showKind?: boolean;
114
+ className?: string;
115
+ }) {
116
+ const parsed = toDate(date);
117
+ const parts: React.ReactNode[] = [];
118
+ if (showKind) parts.push(getFormatLabel(resolveFilePreviewKind(filename, mimeType)));
119
+ if (size !== undefined) parts.push(formatBytes(size));
120
+ if (parsed) {
121
+ parts.push(
122
+ <time key="date" dateTime={parsed.toISOString()} suppressHydrationWarning>
123
+ {DATE_FORMAT.format(parsed)}
124
+ </time>,
125
+ );
126
+ }
127
+ if (parts.length === 0) return null;
128
+ return (
129
+ <span className={cn("text-xs text-muted-foreground", className)}>
130
+ {parts.map((part, index) => (
131
+ <React.Fragment key={index}>
132
+ {index > 0 && <FactSeparator />}
133
+ {part}
134
+ </React.Fragment>
135
+ ))}
136
+ </span>
137
+ );
138
+ }
139
+
140
+ /** "Dana Ruiz (Client)", or the name alone. */
141
+ export function uploaderLabel(uploader: FileUploader): string {
142
+ return uploader.role ? `${uploader.name} (${uploader.role})` : uploader.name;
143
+ }
144
+
145
+ export interface FileCardProps
146
+ extends FileDetails,
147
+ Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
148
+ /** One sentence under the details, such as why there is no preview. */
149
+ description?: React.ReactNode;
150
+ /** Adds a Download button. */
151
+ onDownload?: () => void;
152
+ /** Further buttons after Download, such as Open or Delete. */
153
+ actions?: React.ReactNode;
154
+ }
155
+
156
+ export const FileCard = React.forwardRef<HTMLDivElement, FileCardProps>(
157
+ (
158
+ {
159
+ filename,
160
+ mimeType,
161
+ size,
162
+ date,
163
+ uploader,
164
+ description,
165
+ onDownload,
166
+ actions,
167
+ className,
168
+ ...props
169
+ },
170
+ ref,
171
+ ) => (
172
+ <div
173
+ ref={ref}
174
+ className={cn(
175
+ "flex min-w-0 flex-col items-center justify-center rounded-[var(--radius-md)] border border-border bg-card px-6 py-12 text-center",
176
+ className,
177
+ )}
178
+ {...props}
179
+ >
180
+ <FileKindTile filename={filename} mimeType={mimeType} size="lg" />
181
+ <p
182
+ className="mt-3 line-clamp-2 max-w-full text-sm font-medium text-foreground [overflow-wrap:anywhere]"
183
+ title={filename}
184
+ >
185
+ {filename}
186
+ </p>
187
+ <FileFacts filename={filename} size={size} date={date} className="mt-1" />
188
+ {uploader && (
189
+ <p className="mt-0.5 max-w-full truncate text-xs text-muted-foreground">
190
+ Uploaded by {uploaderLabel(uploader)}
191
+ </p>
192
+ )}
193
+ {description && (
194
+ <div className="mt-3 max-w-md text-sm text-muted-foreground">{description}</div>
195
+ )}
196
+ {(onDownload || actions) && (
197
+ <div className="mt-5 flex flex-wrap items-center justify-center gap-2">
198
+ {onDownload && (
199
+ <Button type="button" variant="outline" size="sm" onClick={onDownload}>
200
+ <Download />
201
+ Download
202
+ </Button>
203
+ )}
204
+ {actions}
205
+ </div>
206
+ )}
207
+ </div>
208
+ ),
209
+ );
210
+ FileCard.displayName = "FileCard";
@@ -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
+ }