|
1 | 1 | <script lang="ts">
|
2 |
| - import { FileNode, FolderNode, type FileTreeNode } from "$lib/tree.svelte.js"; |
| 2 | + import { FileNode, FileTree, FolderNode, type FileTreeNode } from "$lib/tree.svelte.js"; |
3 | 3 | import { composeEventHandlers, formatSize } from "$lib/utils.js";
|
4 |
| - import { FolderIcon } from "@lucide/svelte"; |
5 | 4 | import { ContextMenu } from "bits-ui";
|
6 | 5 | import {
|
7 | 6 | Tree,
|
|
12 | 11 | import { toast } from "svelte-sonner";
|
13 | 12 | import type { EventHandler } from "svelte/elements";
|
14 | 13 | import { SvelteSet } from "svelte/reactivity";
|
15 |
| - import { fly } from "svelte/transition"; |
| 14 | + import FileDropToastDescription from "./FileDropToastDescription.svelte"; |
16 | 15 | import NameConflictDialog from "./NameConflictDialog.svelte";
|
17 | 16 | import NameFormDialog from "./NameFormDialog.svelte";
|
18 | 17 | import TreeContextMenu from "./TreeContextMenu.svelte";
|
19 | 18 | import TreeItem from "./TreeItem.svelte";
|
20 | 19 | import {
|
21 | 20 | createContextMenuState,
|
| 21 | + createFileDropToastState, |
22 | 22 | createFileInputState,
|
23 | 23 | createNameConflictDialogState,
|
24 | 24 | createNameFormDialogState,
|
25 | 25 | } from "./state.svelte.js";
|
26 |
| - import type { FileDropState, TreeItemState, TreeProps, UploadFilesArgs } from "./types.js"; |
| 26 | + import type { TreeItemState, TreeProps, UploadFilesArgs } from "./types.js"; |
27 | 27 |
|
28 | 28 | let {
|
29 | 29 | tree,
|
|
67 | 67 | let fileInput: HTMLInputElement | null = $state.raw(null);
|
68 | 68 |
|
69 | 69 | let focusedItemId: string | undefined = $state.raw();
|
70 |
| - let fileDropState: FileDropState | undefined = $state.raw(); |
71 |
| -
|
72 |
| - const nameConflictDialogState = createNameConflictDialogState(); |
73 |
| - const nameFormDialogState = createNameFormDialogState(); |
74 |
| - const fileInputState = createFileInputState({ |
75 |
| - ref: () => fileInput, |
76 |
| - }); |
77 |
| -
|
78 | 70 | const pasteDirection = $derived.by(() => {
|
79 | 71 | if (pasteOperation === undefined || focusedItemId === undefined) {
|
80 | 72 | return;
|
|
87 | 79 | return "After";
|
88 | 80 | });
|
89 | 81 |
|
| 82 | + const nameConflictDialogState = createNameConflictDialogState(); |
| 83 | + const nameFormDialogState = createNameFormDialogState(); |
| 84 | + const fileInputState = createFileInputState({ |
| 85 | + ref: () => fileInput, |
| 86 | + }); |
| 87 | +
|
90 | 88 | function showAlreadyExistsToast(name: string) {
|
91 | 89 | toast.error(`An item with the name "${name}" already exists`);
|
92 | 90 | }
|
|
117 | 115 | });
|
118 | 116 | }
|
119 | 117 |
|
120 |
| - async function handleUploadFiles({ target, files }: UploadFilesArgs) { |
| 118 | + function handleUploadFiles({ target, files }: UploadFilesArgs) { |
121 | 119 | for (const child of target.children) {
|
122 | 120 | for (const file of files) {
|
123 | 121 | if (child.name === file.name) {
|
|
127 | 125 | }
|
128 | 126 | }
|
129 | 127 |
|
130 |
| - const didUpload = await onUploadFiles({ target, files }); |
131 |
| - if (didUpload) { |
132 |
| - // TODO: show toast after upload is done |
133 |
| - } |
| 128 | + const uploadPromise = Promise.resolve(onUploadFiles({ target, files })); |
| 129 | + const name = target instanceof FileTree ? "/" : target.name; |
| 130 | + toast.promise(uploadPromise, { |
| 131 | + loading: `Uploading ${files.length} file(s) to ${name}`, |
| 132 | + success: (didUpload) => { |
| 133 | + if (!didUpload) { |
| 134 | + return "Failed to upload files"; |
| 135 | + } |
| 136 | +
|
| 137 | + return `Uploaded ${files.length} file(s) to ${name}`; |
| 138 | + }, |
| 139 | + error: (error) => { |
| 140 | + throw error; |
| 141 | + }, |
| 142 | + }); |
134 | 143 | }
|
135 | 144 |
|
136 | 145 | const contextMenuState = createContextMenuState({
|
|
164 | 173 | },
|
165 | 174 | });
|
166 | 175 |
|
167 |
| - const handleTriggerContextMenu: EventHandler<Event, HTMLDivElement> = (event) => { |
168 |
| - if (event.defaultPrevented) { |
169 |
| - // A tree item handled the event. |
170 |
| - return; |
171 |
| - } |
172 |
| -
|
173 |
| - if (event.target instanceof Element && event.target.closest("[role='treeitem']") === null) { |
174 |
| - contextMenuState.setTarget({ |
175 |
| - type: "tree", |
176 |
| - tree: () => tree, |
| 176 | + const fileDropToastState = createFileDropToastState({ |
| 177 | + onShow: ({ target, toastId }) => { |
| 178 | + return toast("Drop files to upload them to:", { |
| 179 | + description: FileDropToastDescription as any, |
| 180 | + componentProps: { |
| 181 | + description: target.type === "tree" ? "/" : target.item().node.name, |
| 182 | + }, |
| 183 | + classes: { |
| 184 | + toast: "pointer-events-none !bg-blue-100", |
| 185 | + title: "!text-sm !font-semibold", |
| 186 | + }, |
| 187 | + id: toastId, |
| 188 | + duration: Number.POSITIVE_INFINITY, |
177 | 189 | });
|
178 |
| - } |
179 |
| - }; |
| 190 | + }, |
| 191 | + onDismiss: ({ toastId }) => toast.dismiss(toastId), |
| 192 | + }); |
180 | 193 |
|
181 | 194 | function handleResolveNameConflict({ operation, name }: ResolveNameConflictArgs) {
|
182 | 195 | return new Promise<NameConflictResolution>((resolve) => {
|
|
223 | 236 | focusedItemId = undefined;
|
224 | 237 | };
|
225 | 238 |
|
| 239 | + function isOrInsideTreeItem(target: EventTarget | null) { |
| 240 | + return target instanceof Element && target.closest("[role='treeitem']") !== null; |
| 241 | + } |
| 242 | +
|
| 243 | + const handleTriggerContextMenu: EventHandler<Event, HTMLDivElement> = (event) => { |
| 244 | + if (isOrInsideTreeItem(event.target)) { |
| 245 | + return; |
| 246 | + } |
| 247 | +
|
| 248 | + contextMenuState.setTarget({ |
| 249 | + type: "tree", |
| 250 | + tree: () => tree, |
| 251 | + }); |
| 252 | + }; |
| 253 | +
|
226 | 254 | const handleTriggerDragOver: EventHandler<DragEvent, HTMLDivElement> = (event) => {
|
227 |
| - if (event.defaultPrevented) { |
228 |
| - // A tree item handled the event. |
| 255 | + if (isOrInsideTreeItem(event.target)) { |
229 | 256 | return;
|
230 | 257 | }
|
231 | 258 |
|
232 |
| - if (event.dataTransfer?.types.includes("Files")) { |
233 |
| - fileDropState = { |
234 |
| - type: "tree", |
235 |
| - }; |
236 |
| - event.preventDefault(); |
| 259 | + if (!event.dataTransfer?.types.includes("Files")) { |
| 260 | + return; |
237 | 261 | }
|
| 262 | +
|
| 263 | + fileDropToastState.setTarget({ |
| 264 | + type: "tree", |
| 265 | + }); |
| 266 | + event.preventDefault(); |
238 | 267 | };
|
239 | 268 |
|
240 | 269 | const handleTriggerDragLeave: EventHandler<DragEvent, HTMLDivElement> = (event) => {
|
|
243 | 272 | return;
|
244 | 273 | }
|
245 | 274 |
|
246 |
| - fileDropState = undefined; |
| 275 | + fileDropToastState.dismiss(); |
247 | 276 | };
|
248 | 277 |
|
249 | 278 | const handleTriggerDrop: EventHandler<DragEvent, HTMLDivElement> = (event) => {
|
250 |
| - fileDropState = undefined; |
251 |
| -
|
252 |
| - if (event.defaultPrevented) { |
253 |
| - // A tree item handled the event. |
| 279 | + if (isOrInsideTreeItem(event.target)) { |
254 | 280 | return;
|
255 | 281 | }
|
256 | 282 |
|
|
259 | 285 | return;
|
260 | 286 | }
|
261 | 287 |
|
| 288 | + fileDropToastState.dismiss(); |
262 | 289 | handleUploadFiles({
|
263 | 290 | target: tree,
|
264 | 291 | files,
|
|
271 | 298 | focusedItemId = undefined;
|
272 | 299 | }
|
273 | 300 |
|
274 |
| - if (fileDropState?.type === "item" && fileDropState.item() === target) { |
275 |
| - fileDropState = undefined; |
276 |
| - } |
277 |
| -
|
278 | 301 | const menuTarget = contextMenuState.target();
|
279 | 302 | if (menuTarget?.type === "item" && menuTarget.item() === target) {
|
280 | 303 | contextMenuState.close();
|
281 | 304 | }
|
| 305 | +
|
| 306 | + const fileDropToastTarget = fileDropToastState.target(); |
| 307 | + if (fileDropToastTarget?.type === "item" && fileDropToastTarget.item() === target) { |
| 308 | + fileDropToastState.dismiss(); |
| 309 | + } |
282 | 310 | }
|
283 | 311 | </script>
|
284 | 312 |
|
|
327 | 355 | {#snippet item({ item })}
|
328 | 356 | <TreeItem
|
329 | 357 | {item}
|
330 |
| - bind:fileDropState |
| 358 | + fileDropToastTarget={fileDropToastState.target()} |
| 359 | + onFileDropToastTargetChange={fileDropToastState.setTarget} |
| 360 | + onDismissFileDropToast={fileDropToastState.dismiss} |
331 | 361 | onExpand={handleExpand}
|
332 | 362 | onCollapse={handleCollapse}
|
333 | 363 | onRename={handleRename}
|
|
338 | 368 | />
|
339 | 369 | {/snippet}
|
340 | 370 | </Tree>
|
341 |
| - |
342 |
| - {#if fileDropState !== undefined} |
343 |
| - <div |
344 |
| - class="absolute start-4 bottom-4 z-50 rounded-lg bg-blue-200 px-6 py-3 shadow" |
345 |
| - transition:fly={{ x: "-100%" }} |
346 |
| - > |
347 |
| - <div>Drop files to upload them to</div> |
348 |
| - <div class="mt-1 flex items-center justify-center gap-2"> |
349 |
| - <FolderIcon role="presentation" size={16} strokeWidth={3} /> |
350 |
| - <span class="font-semibold"> |
351 |
| - {#if fileDropState.type === "tree"} |
352 |
| - / |
353 |
| - {:else} |
354 |
| - {fileDropState.item().node.name} |
355 |
| - {/if} |
356 |
| - </span> |
357 |
| - </div> |
358 |
| - </div> |
359 |
| - {/if} |
360 | 371 | </ContextMenu.Trigger>
|
361 | 372 | </TreeContextMenu>
|
362 | 373 |
|
|
0 commit comments