Merge pull request #194 from Coffey-Labs/feat/files-multiselect
Select more than one file at a time
This commit is contained in:
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { canDropFileNodes, NODE_MIME, readDraggedIds } from "@/lib/filenode";
|
||||||
|
import type { FileNode, Id } from "@/jmap/types";
|
||||||
|
|
||||||
|
const rights = { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: true };
|
||||||
|
|
||||||
|
function node(id: string, parentId: Id | null, nodeType: FileNode["nodeType"] = "file"): FileNode {
|
||||||
|
return { id, parentId, nodeType, blobId: nodeType === "file" ? `b${id}` : null, size: 1, name: id, type: "text/plain", created: "", modified: null, myRights: rights } as FileNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A multi-file drag carries its ids in one payload, because `dataTransfer`
|
||||||
|
* holds one string per type and the drop has to be one action. These two
|
||||||
|
* functions are the whole of that contract -- the gesture itself cannot be
|
||||||
|
* driven synthetically, so this is what pins it.
|
||||||
|
*/
|
||||||
|
describe("readDraggedIds", () => {
|
||||||
|
const dt = (value: string) => ({ getData: (type: string) => (type === NODE_MIME ? value : "") }) as DataTransfer;
|
||||||
|
|
||||||
|
it("reads one id as a list of one", () => {
|
||||||
|
expect(readDraggedIds(dt("f1"))).toEqual(["f1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a whole selection", () => {
|
||||||
|
expect(readDraggedIds(dt("f1,f2,f3"))).toEqual(["f1", "f2", "f3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is empty for a drag that carries nothing of ours", () => {
|
||||||
|
// A drag from outside the app: the caller checks `types` first, but an
|
||||||
|
// empty string here must not read as a file called "".
|
||||||
|
expect(readDraggedIds(dt(""))).toEqual([]);
|
||||||
|
expect(readDraggedIds(dt(",,"))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canDropFileNodes", () => {
|
||||||
|
const nodes: Record<Id, FileNode> = {
|
||||||
|
root1: node("root1", null),
|
||||||
|
root2: node("root2", null),
|
||||||
|
dir: node("dir", null, "directory"),
|
||||||
|
inside: node("inside", "dir"),
|
||||||
|
};
|
||||||
|
|
||||||
|
it("allows a drop only when every file can make it", () => {
|
||||||
|
expect(canDropFileNodes(nodes, ["root1", "root2"], "dir")).toBe(true);
|
||||||
|
// `inside` is already in `dir`, so the move is a no-op for it -- and a drop
|
||||||
|
// that would move one of two files is refused rather than half-done.
|
||||||
|
expect(canDropFileNodes(nodes, ["root1", "inside"], "dir")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a folder dropped into itself, whoever it is dragged with", () => {
|
||||||
|
expect(canDropFileNodes(nodes, ["dir"], "dir")).toBe(false);
|
||||||
|
expect(canDropFileNodes(nodes, ["root1", "dir"], "dir")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has nothing to drop when nothing is dragged", () => {
|
||||||
|
expect(canDropFileNodes(nodes, [], "dir")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats the top level like any other target", () => {
|
||||||
|
expect(canDropFileNodes(nodes, ["inside"], null)).toBe(true);
|
||||||
|
// Already at the top: nothing to do.
|
||||||
|
expect(canDropFileNodes(nodes, ["root1"], null)).toBe(false);
|
||||||
|
expect(canDropFileNodes(nodes, ["inside", "root1"], null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -54,6 +54,27 @@ export function isShared(node: Pick<FileNode, "shareWith">): boolean {
|
|||||||
* legal moves behind a disabled drop. The server refuses those with a message
|
* legal moves behind a disabled drop. The server refuses those with a message
|
||||||
* of its own, which is a better answer than a silent one.
|
* of its own, which is a better answer than a silent one.
|
||||||
*/
|
*/
|
||||||
|
/** The MIME a dragged node is offered under, so a target can recognise it. */
|
||||||
|
export const NODE_MIME = "application/x-ihasmail-filenode";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ids in a node drag. A multi-file selection is dragged as one payload, so
|
||||||
|
* this is a list even when it holds one -- both drop targets read it the same
|
||||||
|
* way and neither has to care how the drag started.
|
||||||
|
*/
|
||||||
|
export function readDraggedIds(dt: DataTransfer): Id[] {
|
||||||
|
return dt.getData(NODE_MIME).split(",").filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same question for a multi-file drag. Every one of them has to be able to
|
||||||
|
* land, because the drop is one action: allowing a drag that would move four
|
||||||
|
* of five files and silently skip the fifth is worse than refusing it.
|
||||||
|
*/
|
||||||
|
export function canDropFileNodes(nodes: Record<Id, FileNode>, draggedIds: Id[], targetId: Id | null): boolean {
|
||||||
|
return draggedIds.length > 0 && draggedIds.every((id) => canDropFileNode(nodes, id, targetId));
|
||||||
|
}
|
||||||
|
|
||||||
export function canDropFileNode(nodes: Record<Id, FileNode>, draggedId: Id, targetId: Id | null): boolean {
|
export function canDropFileNode(nodes: Record<Id, FileNode>, draggedId: Id, targetId: Id | null): boolean {
|
||||||
const dragged = nodes[draggedId];
|
const dragged = nodes[draggedId];
|
||||||
if (!dragged) return false;
|
if (!dragged) return false;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe("what a switch to another account keeps", () => {
|
|||||||
children: {},
|
children: {},
|
||||||
dirIds: [],
|
dirIds: [],
|
||||||
treeLoaded: false,
|
treeLoaded: false,
|
||||||
draggingId: null,
|
draggingIds: [],
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -41,14 +41,14 @@ describe("what a switch to another account keeps", () => {
|
|||||||
|
|
||||||
it("drops a drag that was in flight", () => {
|
it("drops a drag that was in flight", () => {
|
||||||
// Its id belongs to the other account and would name a different node here.
|
// Its id belongs to the other account and would name a different node here.
|
||||||
expect(emptyForAccount("b").draggingId).toBeNull();
|
expect(emptyForAccount("b").draggingIds).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("names every piece of per-account state", () => {
|
it("names every piece of per-account state", () => {
|
||||||
// Add a per-account field to the store and forget it here, and this fails
|
// Add a per-account field to the store and forget it here, and this fails
|
||||||
// rather than the field quietly following someone into another account.
|
// rather than the field quietly following someone into another account.
|
||||||
expect(Object.keys(emptyForAccount(null)).sort()).toEqual(
|
expect(Object.keys(emptyForAccount(null)).sort()).toEqual(
|
||||||
["accountId", "children", "dirIds", "draggingId", "error", "nodes", "treeLoaded"],
|
["accountId", "children", "dirIds", "draggingIds", "error", "nodes", "treeLoaded"],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+28
-11
@@ -48,7 +48,7 @@ interface FilesState {
|
|||||||
* It cannot be read from the drag itself: `dataTransfer.getData` is blocked
|
* It cannot be read from the drag itself: `dataTransfer.getData` is blocked
|
||||||
* during dragover, which is exactly when the answer is needed.
|
* during dragover, which is exactly when the answer is needed.
|
||||||
*/
|
*/
|
||||||
draggingId: Id | null;
|
draggingIds: Id[];
|
||||||
|
|
||||||
init(): Promise<void>;
|
init(): Promise<void>;
|
||||||
/** Browse an account: the reader's own, or one shared with them. */
|
/** Browse an account: the reader's own, or one shared with them. */
|
||||||
@@ -64,9 +64,11 @@ interface FilesState {
|
|||||||
*/
|
*/
|
||||||
saveText(id: Id, text: string, seenBlobId: Id | null): Promise<Id>;
|
saveText(id: Id, text: string, seenBlobId: Id | null): Promise<Id>;
|
||||||
move(id: Id, parentId: Id | null): Promise<void>;
|
move(id: Id, parentId: Id | null): Promise<void>;
|
||||||
|
/** Move several at once, in one round trip -- see the note on the implementation. */
|
||||||
|
moveMany(ids: Id[], parentId: Id | null): Promise<void>;
|
||||||
destroy(ids: Id[]): Promise<void>;
|
destroy(ids: Id[]): Promise<void>;
|
||||||
refresh(ids: Id[]): Promise<void>;
|
refresh(ids: Id[]): Promise<void>;
|
||||||
setDragging(id: Id | null): void;
|
setDragging(ids: Id[]): void;
|
||||||
/** Every directory in the account, for the tree in the sidebar. */
|
/** Every directory in the account, for the tree in the sidebar. */
|
||||||
loadTree(): Promise<void>;
|
loadTree(): Promise<void>;
|
||||||
/** Upload a planned drop, creating the folders it needs as it goes. */
|
/** Upload a planned drop, creating the folders it needs as it goes. */
|
||||||
@@ -115,7 +117,7 @@ export function withoutAppFolder(nodes: FileNode[]): FileNode[] {
|
|||||||
* accounts.
|
* accounts.
|
||||||
*/
|
*/
|
||||||
export function emptyForAccount(accountId: Id | null) {
|
export function emptyForAccount(accountId: Id | null) {
|
||||||
return { accountId, nodes: {}, children: {}, dirIds: [], treeLoaded: false, draggingId: null, error: null };
|
return { accountId, nodes: {}, children: {}, dirIds: [], treeLoaded: false, draggingIds: [], error: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useFiles = create<FilesState>((set, get) => ({
|
export const useFiles = create<FilesState>((set, get) => ({
|
||||||
@@ -130,7 +132,7 @@ export const useFiles = create<FilesState>((set, get) => ({
|
|||||||
uploads: [],
|
uploads: [],
|
||||||
dirIds: [],
|
dirIds: [],
|
||||||
treeLoaded: false,
|
treeLoaded: false,
|
||||||
draggingId: null,
|
draggingIds: [],
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
const session = useSession.getState();
|
const session = useSession.getState();
|
||||||
@@ -276,8 +278,8 @@ export const useFiles = create<FilesState>((set, get) => ({
|
|||||||
/* Re-read named nodes in place. Sharing changes one property of one node and
|
/* Re-read named nodes in place. Sharing changes one property of one node and
|
||||||
nothing about which folder it sits in, so reloading the level around it
|
nothing about which folder it sits in, so reloading the level around it
|
||||||
would be a bigger round trip to land in the same place. */
|
would be a bigger round trip to land in the same place. */
|
||||||
setDragging(id) {
|
setDragging(ids) {
|
||||||
set({ draggingId: id });
|
set({ draggingIds: ids });
|
||||||
},
|
},
|
||||||
|
|
||||||
async refresh(ids) {
|
async refresh(ids) {
|
||||||
@@ -353,12 +355,27 @@ export const useFiles = create<FilesState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
async move(id, parentId) {
|
async move(id, parentId) {
|
||||||
|
await get().moveMany([id], parentId);
|
||||||
|
},
|
||||||
|
|
||||||
|
/*
|
||||||
|
* One `FileNode/set` for the lot rather than one per file.
|
||||||
|
*
|
||||||
|
* Not only for the round trip: a loop would apply half the moves and then
|
||||||
|
* throw, leaving a selection split across two folders with nothing saying
|
||||||
|
* which half went. One call is one answer, and `notUpdated` names whichever
|
||||||
|
* ones the server refused.
|
||||||
|
*/
|
||||||
|
async moveMany(ids, parentId) {
|
||||||
|
if (!ids.length) return;
|
||||||
const accountId = get().accountId!;
|
const accountId = get().accountId!;
|
||||||
const from = get().nodes[id]?.parentId ?? null;
|
const from = new Set(ids.map((id) => get().nodes[id]?.parentId ?? null));
|
||||||
const res = await client.call<SetResponse>("FileNode/set", { accountId, update: { [id]: { parentId } } });
|
const update = Object.fromEntries(ids.map((id) => [id, { parentId }]));
|
||||||
const err = res.notUpdated?.[id];
|
const res = await client.call<SetResponse>("FileNode/set", { accountId, update });
|
||||||
if (err) throw new Error(setErrorMessage(err));
|
const failed = Object.values(res.notUpdated ?? {})[0];
|
||||||
await Promise.all([get().loadChildren(from), get().loadChildren(parentId)]);
|
if (failed) throw new Error(setErrorMessage(failed));
|
||||||
|
from.add(parentId);
|
||||||
|
for (const p of from) await get().loadChildren(p);
|
||||||
void get().loadTree();
|
void get().loadTree();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1355,6 +1355,10 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
|
|||||||
/* Files: the sidebar tree reuses .nav-item, so only the parts the mail tree has
|
/* Files: the sidebar tree reuses .nav-item, so only the parts the mail tree has
|
||||||
no equivalent for are here. A row in the list is a drop target the same way a
|
no equivalent for are here. A row in the list is a drop target the same way a
|
||||||
folder in the tree is, and says so the same way. */
|
folder in the tree is, and says so the same way. */
|
||||||
|
/* Shown only when more than one row is selected: with a single row the row
|
||||||
|
menu already says everything this would. */
|
||||||
|
.selection-bar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; margin: 0 12px 4px; border-radius: var(--radius); background: var(--accent-soft); color: var(--accent-soft-fg); }
|
||||||
|
.selection-bar .grow { font-weight: 600; }
|
||||||
.files-table tbody tr.drop-target > td { background: var(--accent-soft); }
|
.files-table tbody tr.drop-target > td { background: var(--accent-soft); }
|
||||||
.files-table tbody tr.drop-target > td:first-child { box-shadow: inset 2px 0 0 var(--accent); }
|
.files-table tbody tr.drop-target > td:first-child { box-shadow: inset 2px 0 0 var(--accent); }
|
||||||
.files-table tbody tr[draggable="true"] { cursor: grab; }
|
.files-table tbody tr[draggable="true"] { cursor: grab; }
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ChevronDown, ChevronRight, Folder, FolderOpen, FolderPlus, HardDrive, P
|
|||||||
import { useFiles } from "@/store/files";
|
import { useFiles } from "@/store/files";
|
||||||
import { useSession } from "@/store/session";
|
import { useSession } from "@/store/session";
|
||||||
import type { FileNode, Id } from "@/jmap/types";
|
import type { FileNode, Id } from "@/jmap/types";
|
||||||
import { canDropFileNode, isShared } from "@/lib/filenode";
|
import { canDropFileNodes, NODE_MIME, readDraggedIds, isShared } from "@/lib/filenode";
|
||||||
import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/dropUpload";
|
import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/dropUpload";
|
||||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||||
@@ -34,8 +34,6 @@ async function refreshShares(force = false): Promise<void> {
|
|||||||
await useFiles.getState().init();
|
await useFiles.getState().init();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The MIME a dragged node is offered under, so a target can recognise it. */
|
|
||||||
export const NODE_MIME = "application/x-ihasmail-filenode";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The folder tree beside the file list.
|
* The folder tree beside the file list.
|
||||||
@@ -66,8 +64,8 @@ export function FilesTree() {
|
|||||||
|
|
||||||
/* Shared with the list pane: a drag starting in one has to be recognised by
|
/* Shared with the list pane: a drag starting in one has to be recognised by
|
||||||
the other. See the note on `draggingId` in the store. */
|
the other. See the note on `draggingId` in the store. */
|
||||||
const draggingId = useFiles((s) => s.draggingId);
|
const draggingIds = useFiles((s) => s.draggingIds);
|
||||||
const setDraggingId = useFiles((s) => s.setDragging);
|
const setDragging = useFiles((s) => s.setDragging);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (available && !treeLoaded) void loadTree();
|
if (available && !treeLoaded) void loadTree();
|
||||||
@@ -103,12 +101,12 @@ export function FilesTree() {
|
|||||||
|
|
||||||
const dirs = dirIds.map((id) => nodes[id]).filter((n): n is FileNode => Boolean(n));
|
const dirs = dirIds.map((id) => nodes[id]).filter((n): n is FileNode => Boolean(n));
|
||||||
const childrenOf = (parentId: Id | null) => dirs.filter((d) => (d.parentId ?? null) === parentId);
|
const childrenOf = (parentId: Id | null) => dirs.filter((d) => (d.parentId ?? null) === parentId);
|
||||||
const canDropOn = (targetId: Id | null) => Boolean(draggingId) && canDropFileNode(nodes, draggingId!, targetId);
|
const canDropOn = (targetId: Id | null) => canDropFileNodes(nodes, draggingIds, targetId);
|
||||||
|
|
||||||
const moveTo = async (id: Id, parentId: Id | null) => {
|
const moveTo = async (ids: Id[], parentId: Id | null) => {
|
||||||
setDraggingId(null);
|
setDragging([]);
|
||||||
try {
|
try {
|
||||||
await useFiles.getState().move(id, parentId);
|
await useFiles.getState().moveMany(ids, parentId);
|
||||||
if (parentId) setExpanded((x) => ({ ...x, [parentId]: true }));
|
if (parentId) setExpanded((x) => ({ ...x, [parentId]: true }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error((err as Error).message);
|
toast.error((err as Error).message);
|
||||||
@@ -132,8 +130,8 @@ export function FilesTree() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setRootDrop(false);
|
setRootDrop(false);
|
||||||
if (e.dataTransfer.types.includes(NODE_MIME)) {
|
if (e.dataTransfer.types.includes(NODE_MIME)) {
|
||||||
const id = e.dataTransfer.getData(NODE_MIME);
|
const ids = readDraggedIds(e.dataTransfer);
|
||||||
if (id && canDropFileNode(nodes, id, targetId)) void moveTo(id, targetId);
|
if (canDropFileNodes(nodes, ids, targetId)) void moveTo(ids, targetId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.dataTransfer.types.includes("Files")) void dropFiles(targetId, e.dataTransfer);
|
if (e.dataTransfer.types.includes("Files")) void dropFiles(targetId, e.dataTransfer);
|
||||||
@@ -153,13 +151,13 @@ export function FilesTree() {
|
|||||||
return (
|
return (
|
||||||
<div key={d.id}>
|
<div key={d.id}>
|
||||||
<div
|
<div
|
||||||
className={`nav-item ${currentId === d.id ? "active" : ""} ${draggingId && canDropOn(d.id) ? "drop-target" : ""}`}
|
className={`nav-item ${currentId === d.id ? "active" : ""} ${draggingIds.length && canDropOn(d.id) ? "drop-target" : ""}`}
|
||||||
style={{ paddingLeft: 8 + depth * 14 }}
|
style={{ paddingLeft: 8 + depth * 14 }}
|
||||||
onClick={() => navigate(`/files/${d.id}`)}
|
onClick={() => navigate(`/files/${d.id}`)}
|
||||||
onContextMenu={(e) => { e.preventDefault(); setMenuNode(d); menu.openAt(e.clientX, e.clientY); }}
|
onContextMenu={(e) => { e.preventDefault(); setMenuNode(d); menu.openAt(e.clientX, e.clientY); }}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => { e.dataTransfer.setData(NODE_MIME, d.id); e.dataTransfer.effectAllowed = "move"; setDraggingId(d.id); }}
|
onDragStart={(e) => { e.dataTransfer.setData(NODE_MIME, d.id); e.dataTransfer.effectAllowed = "move"; setDragging([d.id]); }}
|
||||||
onDragEnd={() => setDraggingId(null)}
|
onDragEnd={() => setDragging([])}
|
||||||
onDragOver={onDragOver(d.id)}
|
onDragOver={onDragOver(d.id)}
|
||||||
onDrop={onDrop(d.id)}
|
onDrop={onDrop(d.id)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,31 +1,34 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { ChevronRight, Download, Eye, File, FilePen, Folder, FolderPlus, FolderOpen, Home, MoreVertical, Pencil, Share2, Trash2, Upload, FolderInput } from "lucide-react";
|
import { ChevronRight, Download, Eye, File, FilePen, Folder, FolderPlus, FolderOpen, Home, MoreVertical, Pencil, Share2, Trash2, Upload, FolderInput, X } from "lucide-react";
|
||||||
import { useFiles } from "@/store/files";
|
import { useFiles } from "@/store/files";
|
||||||
import { client } from "@/jmap/client";
|
import { client } from "@/jmap/client";
|
||||||
import type { FileNode, Id } from "@/jmap/types";
|
import type { FileNode, Id } from "@/jmap/types";
|
||||||
import { formatSize, formatListDate } from "@/lib/format";
|
import { formatSize, formatListDate } from "@/lib/format";
|
||||||
import { canDropFileNode, isShared } from "@/lib/filenode";
|
import { canDropFileNodes, isShared, NODE_MIME, readDraggedIds } from "@/lib/filenode";
|
||||||
import { previewKind } from "@/lib/preview";
|
import { previewKind } from "@/lib/preview";
|
||||||
import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/dropUpload";
|
import { entriesFromDrop, hasDirectory, planUpload } from "@/lib/dropUpload";
|
||||||
import { NODE_MIME } from "./FilesTree";
|
|
||||||
import { ShareDialog } from "../settings/ShareDialog";
|
import { ShareDialog } from "../settings/ShareDialog";
|
||||||
import { Empty, Spinner } from "@/ui/misc";
|
import { Empty, Spinner } from "@/ui/misc";
|
||||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||||
import { confirmDialog, promptDialog, Dialog } from "@/ui/dialog";
|
import { confirmDialog, promptDialog, Dialog } from "@/ui/dialog";
|
||||||
import { FilePreviewDialog, type PreviewFile } from "@/ui/filepreview";
|
import { FilePreviewDialog, type PreviewFile } from "@/ui/filepreview";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { t } from "@/lib/i18n";
|
import { plural, t } from "@/lib/i18n";
|
||||||
|
|
||||||
export function FilesView({ nodeId }: { nodeId?: string }) {
|
export function FilesView({ nodeId }: { nodeId?: string }) {
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
const files = useFiles();
|
const files = useFiles();
|
||||||
const parentId = nodeId ?? null;
|
const parentId = nodeId ?? null;
|
||||||
const [dropping, setDropping] = useState(false);
|
const [dropping, setDropping] = useState(false);
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
/* A set, and the row a shift-click measures from. Kept as ids rather than
|
||||||
|
indices: the listing reloads under you -- a push, an upload finishing --
|
||||||
|
and an index would then point at a different file. */
|
||||||
|
const [selection, setSelection] = useState<Set<Id>>(() => new Set());
|
||||||
|
const [anchor, setAnchor] = useState<Id | null>(null);
|
||||||
const menu = useMenu();
|
const menu = useMenu();
|
||||||
const [menuNode, setMenuNode] = useState<FileNode | null>(null);
|
const [menuNode, setMenuNode] = useState<FileNode | null>(null);
|
||||||
const [moveNode, setMoveNode] = useState<FileNode | null>(null);
|
const [moveNodes, setMoveNodes] = useState<FileNode[] | null>(null);
|
||||||
const [shareNode, setShareNode] = useState<FileNode | null>(null);
|
const [shareNode, setShareNode] = useState<FileNode | null>(null);
|
||||||
const [preview, setPreview] = useState<PreviewFile | null>(null);
|
const [preview, setPreview] = useState<PreviewFile | null>(null);
|
||||||
/* What the open editor is editing, and the blob its text came from -- the
|
/* What the open editor is editing, and the blob its text came from -- the
|
||||||
@@ -35,10 +38,27 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
|||||||
const [startInEdit, setStartInEdit] = useState(false);
|
const [startInEdit, setStartInEdit] = useState(false);
|
||||||
/* Shared with the sidebar tree, so a row dragged onto a folder there is
|
/* Shared with the sidebar tree, so a row dragged onto a folder there is
|
||||||
recognised. See the note on `draggingId` in the store. */
|
recognised. See the note on `draggingId` in the store. */
|
||||||
const draggingId = files.draggingId;
|
const draggingIds = files.draggingIds;
|
||||||
const setDraggingId = files.setDragging;
|
const setDragging = files.setDragging;
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
/* A selection belongs to the folder it was made in. Carrying it across would
|
||||||
|
leave rows selected that are no longer on screen, and the delete two
|
||||||
|
folders later would be a surprise. */
|
||||||
|
useEffect(() => {
|
||||||
|
setSelection(new Set());
|
||||||
|
setAnchor(null);
|
||||||
|
}, [parentId, files.accountId]);
|
||||||
|
|
||||||
|
/* Escape drops it, the way it does everywhere else. */
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (ev: KeyboardEvent) => {
|
||||||
|
if (ev.key === "Escape") setSelection((cur) => (cur.size ? new Set() : cur));
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (files.available) void files.loadChildren(parentId);
|
if (files.available) void files.loadChildren(parentId);
|
||||||
// `accountId` is in here because opening a share changes which account the
|
// `accountId` is in here because opening a share changes which account the
|
||||||
@@ -86,10 +106,11 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDropping(false);
|
setDropping(false);
|
||||||
if (e.dataTransfer.types.includes(NODE_MIME)) {
|
if (e.dataTransfer.types.includes(NODE_MIME)) {
|
||||||
const id = e.dataTransfer.getData(NODE_MIME);
|
const ids = readDraggedIds(e.dataTransfer);
|
||||||
setDraggingId(null);
|
setDragging([]);
|
||||||
if (id && canDropFileNode(files.nodes, id, into)) {
|
if (canDropFileNodes(files.nodes, ids, into)) {
|
||||||
void files.move(id, into).catch((err) => toast.error((err as Error).message));
|
setSelection(new Set());
|
||||||
|
void files.moveMany(ids, into).catch((err) => toast.error((err as Error).message));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -119,6 +140,66 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
|||||||
a.click();
|
a.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Clicking a row, with the conventions a file manager has taught everyone:
|
||||||
|
* plain replaces the selection, ctrl/cmd adds or removes one, shift takes
|
||||||
|
* the run from the last row clicked to this one. The anchor is the row a
|
||||||
|
* shift measures from, and a plain or toggling click moves it.
|
||||||
|
*/
|
||||||
|
const clickRow = (n: FileNode, ev: React.MouseEvent) => {
|
||||||
|
if (ev.shiftKey && anchor) {
|
||||||
|
const from = nodes.findIndex((x) => x.id === anchor);
|
||||||
|
const to = nodes.findIndex((x) => x.id === n.id);
|
||||||
|
if (from >= 0 && to >= 0) {
|
||||||
|
const run = nodes.slice(Math.min(from, to), Math.max(from, to) + 1).map((x) => x.id);
|
||||||
|
setSelection(new Set(ev.ctrlKey || ev.metaKey ? [...selection, ...run] : run));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ev.ctrlKey || ev.metaKey) {
|
||||||
|
const next = new Set(selection);
|
||||||
|
if (next.has(n.id)) next.delete(n.id);
|
||||||
|
else next.add(n.id);
|
||||||
|
setSelection(next);
|
||||||
|
setAnchor(n.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelection(new Set([n.id]));
|
||||||
|
setAnchor(n.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Right-clicking inside the selection acts on all of it; right-clicking
|
||||||
|
outside it means you meant that row, so the selection follows the pointer
|
||||||
|
rather than the menu quietly applying to something off-screen. */
|
||||||
|
const menuFor = (n: FileNode, at: (x: number, y: number) => void, x: number, y: number) => {
|
||||||
|
if (!selection.has(n.id)) {
|
||||||
|
setSelection(new Set([n.id]));
|
||||||
|
setAnchor(n.id);
|
||||||
|
}
|
||||||
|
setMenuNode(n);
|
||||||
|
at(x, y);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedNodes = () => nodes.filter((n) => selection.has(n.id));
|
||||||
|
/* What the menu and the bar act on: the whole selection when the row is part
|
||||||
|
of it, and that row alone otherwise. */
|
||||||
|
const targets = (n: FileNode | null) => (n && selection.has(n.id) && selection.size > 1 ? selectedNodes() : n ? [n] : selectedNodes());
|
||||||
|
|
||||||
|
const removeNodes = async (list: FileNode[]) => {
|
||||||
|
if (!list.length) return;
|
||||||
|
const title = list.length === 1
|
||||||
|
? t("Delete “{name}”?", { name: list[0]!.name })
|
||||||
|
: plural(list.length, { one: "Delete {n} item?", other: "Delete {n} items?" });
|
||||||
|
if (!(await confirmDialog({ title, confirmLabel: t("Delete"), danger: true }))) return;
|
||||||
|
try {
|
||||||
|
await files.destroy(list.map((n) => n.id));
|
||||||
|
setSelection(new Set());
|
||||||
|
toast.success(t("Deleted"));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/* A file with nothing to show still does what it always did. */
|
/* A file with nothing to show still does what it always did. */
|
||||||
const canPreview = (n: FileNode) => Boolean(n.blobId) && n.nodeType !== "directory" && previewKind(n.type, n.name) !== null;
|
const canPreview = (n: FileNode) => Boolean(n.blobId) && n.nodeType !== "directory" && previewKind(n.type, n.name) !== null;
|
||||||
|
|
||||||
@@ -160,7 +241,7 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`files-layout ${dropping ? "dropping" : ""}`} onDragOver={(e) => { if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } else if (e.dataTransfer.types.includes(NODE_MIME) && canDropFileNode(files.nodes, draggingId ?? "", parentId)) { e.preventDefault(); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop}>
|
<div className={`files-layout ${dropping ? "dropping" : ""}`} onDragOver={(e) => { if (e.dataTransfer.types.includes("Files")) { e.preventDefault(); setDropping(true); } else if (e.dataTransfer.types.includes(NODE_MIME) && canDropFileNodes(files.nodes, draggingIds, parentId)) { e.preventDefault(); } }} onDragLeave={() => setDropping(false)} onDrop={onDrop}>
|
||||||
<div className="files-toolbar">
|
<div className="files-toolbar">
|
||||||
<div className="breadcrumb">
|
<div className="breadcrumb">
|
||||||
<button className={path.length ? "" : "current"} onClick={() => navigate("/files")}><Home size={16} /></button>
|
<button className={path.length ? "" : "current"} onClick={() => navigate("/files")}><Home size={16} /></button>
|
||||||
@@ -180,9 +261,24 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
|||||||
{files.uploads.map((u) => <div key={u.id} className="row"><span className="truncate grow">{u.name}</span>{u.error ? <span style={{ color: "var(--danger)" }}>{u.error}</span> : <span>{u.progress}%</span>}</div>)}
|
{files.uploads.map((u) => <div key={u.id} className="row"><span className="truncate grow">{u.name}</span>{u.error ? <span style={{ color: "var(--danger)" }}>{u.error}</span> : <span>{u.progress}%</span>}</div>)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{selection.size > 1 && (
|
||||||
|
<div className="selection-bar">
|
||||||
|
<span className="grow">{plural(selection.size, { one: "{n} item selected", other: "{n} items selected" })}</span>
|
||||||
|
<button className="btn btn-sm" onClick={() => setMoveNodes(selectedNodes())}><FolderInput size={16} /> {t("Move to…")}</button>
|
||||||
|
<button className="btn btn-sm btn-danger" onClick={() => void removeNodes(selectedNodes())}><Trash2 size={16} /> {t("Delete")}</button>
|
||||||
|
<button className="icon-btn sm" aria-label={t("Clear selection")} title={t("Clear selection")} onClick={() => setSelection(new Set())}><X size={16} /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{files.error && <div className="error-box" style={{ margin: 12 }}>{files.error}</div>}
|
{files.error && <div className="error-box" style={{ margin: 12 }}>{files.error}</div>}
|
||||||
<div
|
<div
|
||||||
className="files-scroll"
|
className="files-scroll"
|
||||||
|
/* Clicking past the last row clears the selection, the way it does in
|
||||||
|
every file manager. Rows stop the click from reaching here by
|
||||||
|
handling it themselves, so this only ever sees the empty space. */
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("tr")) return;
|
||||||
|
setSelection((cur) => (cur.size ? new Set() : cur));
|
||||||
|
}}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
// Only the empty space below the rows: a row has its own menu.
|
// Only the empty space below the rows: a row has its own menu.
|
||||||
if ((e.target as HTMLElement).closest("tr")) return;
|
if ((e.target as HTMLElement).closest("tr")) return;
|
||||||
@@ -200,24 +296,32 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
|||||||
{nodes.map((n) => (
|
{nodes.map((n) => (
|
||||||
<tr
|
<tr
|
||||||
key={n.id}
|
key={n.id}
|
||||||
className={`${selected === n.id ? "selected" : ""} ${draggingId && n.nodeType === "directory" && canDropFileNode(files.nodes, draggingId, n.id) ? "drop-target" : ""}`}
|
className={`${selection.has(n.id) ? "selected" : ""} ${n.nodeType === "directory" && canDropFileNodes(files.nodes, draggingIds, n.id) ? "drop-target" : ""}`}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => { e.dataTransfer.setData(NODE_MIME, n.id); e.dataTransfer.effectAllowed = "move"; setDraggingId(n.id); }}
|
onDragStart={(e) => {
|
||||||
onDragEnd={() => setDraggingId(null)}
|
/* Dragging a row that is part of the selection drags all of
|
||||||
|
it; dragging one outside the selection means that row. */
|
||||||
|
const ids = selection.has(n.id) ? [...selection] : [n.id];
|
||||||
|
if (!selection.has(n.id)) { setSelection(new Set([n.id])); setAnchor(n.id); }
|
||||||
|
e.dataTransfer.setData(NODE_MIME, ids.join(","));
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
setDragging(ids);
|
||||||
|
}}
|
||||||
|
onDragEnd={() => setDragging([])}
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
if (n.nodeType !== "directory") return;
|
if (n.nodeType !== "directory") return;
|
||||||
const node = e.dataTransfer.types.includes(NODE_MIME);
|
const node = e.dataTransfer.types.includes(NODE_MIME);
|
||||||
if (node ? !(draggingId && canDropFileNode(files.nodes, draggingId, n.id)) : !e.dataTransfer.types.includes("Files")) return;
|
if (node ? !canDropFileNodes(files.nodes, draggingIds, n.id) : !e.dataTransfer.types.includes("Files")) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.dataTransfer.dropEffect = node ? "move" : "copy";
|
e.dataTransfer.dropEffect = node ? "move" : "copy";
|
||||||
}}
|
}}
|
||||||
onDrop={(e) => { if (n.nodeType === "directory") dropOnto(n.id, e); }}
|
onDrop={(e) => { if (n.nodeType === "directory") dropOnto(n.id, e); }}
|
||||||
onClick={() => setSelected(n.id)} onDoubleClick={() => activate(n)} onContextMenu={(e) => { e.preventDefault(); setMenuNode(n); menu.openAt(e.clientX, e.clientY); }}>
|
onClick={(e) => clickRow(n, e)} onDoubleClick={() => activate(n)} onContextMenu={(e) => { e.preventDefault(); menuFor(n, menu.openAt, e.clientX, e.clientY); }}>
|
||||||
<td><div className="f-name">{n.nodeType === "directory" ? <Folder size={18} /> : <File size={18} />}<span onClick={(e) => { if (n.nodeType === "directory") { e.stopPropagation(); navigate(`/files/${n.id}`); } }} style={n.nodeType === "directory" ? { cursor: "pointer" } : undefined}>{n.name}</span>{isShared(n) && <Share2 size={13} className="faint" aria-label={t("Shared")} />}</div></td>
|
<td><div className="f-name">{n.nodeType === "directory" ? <Folder size={18} /> : <File size={18} />}<span onClick={(e) => { if (n.nodeType === "directory") { e.stopPropagation(); navigate(`/files/${n.id}`); } }} style={n.nodeType === "directory" ? { cursor: "pointer" } : undefined}>{n.name}</span>{isShared(n) && <Share2 size={13} className="faint" aria-label={t("Shared")} />}</div></td>
|
||||||
<td className="hide-mobile muted">{n.nodeType === "directory" ? "—" : formatSize(n.size)}</td>
|
<td className="hide-mobile muted">{n.nodeType === "directory" ? "—" : formatSize(n.size)}</td>
|
||||||
<td className="hide-mobile muted">{formatListDate(n.modified ?? n.created)}</td>
|
<td className="hide-mobile muted">{formatListDate(n.modified ?? n.created)}</td>
|
||||||
<td style={{ textAlign: "right" }}><button className="icon-btn sm" onClick={(e) => { e.stopPropagation(); setMenuNode(n); menu.open(e); }} aria-label={t("Options")}><MoreVertical size={16} /></button></td>
|
<td style={{ textAlign: "right" }}><button className="icon-btn sm" onClick={(e) => { e.stopPropagation(); if (!selection.has(n.id)) { setSelection(new Set([n.id])); setAnchor(n.id); } setMenuNode(n); menu.open(e); }} aria-label={t("Options")}><MoreVertical size={16} /></button></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -231,7 +335,14 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
|||||||
<MenuItem icon={<FolderPlus size={16} />} label={t("New folder")} onClick={async () => { const n = await promptDialog({ title: t("New folder"), placeholder: t("Folder name") }); if (n?.trim()) { try { await files.mkdir(parentId, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
|
<MenuItem icon={<FolderPlus size={16} />} label={t("New folder")} onClick={async () => { const n = await promptDialog({ title: t("New folder"), placeholder: t("Folder name") }); if (n?.trim()) { try { await files.mkdir(parentId, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{menuNode && (
|
{menuNode && targets(menuNode).length > 1 && (
|
||||||
|
<>
|
||||||
|
<MenuItem icon={<FolderInput size={16} />} label={plural(targets(menuNode).length, { one: "Move {n} item…", other: "Move {n} items…" })} onClick={() => setMoveNodes(targets(menuNode))} />
|
||||||
|
<MenuSep />
|
||||||
|
<MenuItem danger icon={<Trash2 size={16} />} label={plural(targets(menuNode).length, { one: "Delete {n} item", other: "Delete {n} items" })} onClick={() => void removeNodes(targets(menuNode))} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{menuNode && targets(menuNode).length <= 1 && (
|
||||||
<>
|
<>
|
||||||
{menuNode.nodeType === "directory" ? <MenuItem icon={<FolderOpen size={16} />} label={t("Open")} onClick={() => navigate(`/files/${menuNode.id}`)} /> : (
|
{menuNode.nodeType === "directory" ? <MenuItem icon={<FolderOpen size={16} />} label={t("Open")} onClick={() => navigate(`/files/${menuNode.id}`)} /> : (
|
||||||
<>
|
<>
|
||||||
@@ -241,14 +352,14 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<MenuItem icon={<Pencil size={16} />} label={t("Rename")} disabled={!menuNode.myRights?.mayRename} onClick={async () => { const n = await promptDialog({ title: t("Rename"), defaultValue: menuNode.name }); if (n?.trim() && n !== menuNode.name) { try { await files.rename(menuNode.id, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
|
<MenuItem icon={<Pencil size={16} />} label={t("Rename")} disabled={!menuNode.myRights?.mayRename} onClick={async () => { const n = await promptDialog({ title: t("Rename"), defaultValue: menuNode.name }); if (n?.trim() && n !== menuNode.name) { try { await files.rename(menuNode.id, n.trim()); } catch (err) { toast.error((err as Error).message); } } }} />
|
||||||
<MenuItem icon={<FolderInput size={16} />} label={t("Move to…")} onClick={() => setMoveNode(menuNode)} />
|
<MenuItem icon={<FolderInput size={16} />} label={t("Move to…")} onClick={() => setMoveNodes([menuNode])} />
|
||||||
<MenuItem icon={<Share2 size={16} />} label={t("Share…")} disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} />
|
<MenuItem icon={<Share2 size={16} />} label={t("Share…")} disabled={!menuNode.myRights?.mayShare} onClick={() => setShareNode(menuNode)} />
|
||||||
<MenuSep />
|
<MenuSep />
|
||||||
<MenuItem danger icon={<Trash2 size={16} />} label={t("Delete")} disabled={!menuNode.myRights?.mayDelete} onClick={async () => { if (await confirmDialog({ title: t("Delete “{name}”?", { name: menuNode.name }), confirmLabel: t("Delete"), danger: true })) { try { await files.destroy([menuNode.id]); toast.success(t("Deleted")); } catch (err) { toast.error((err as Error).message); } } }} />
|
<MenuItem danger icon={<Trash2 size={16} />} label={t("Delete")} disabled={!menuNode.myRights?.mayDelete} onClick={() => void removeNodes([menuNode])} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
{moveNode && <MoveDialog node={moveNode} onClose={() => setMoveNode(null)} />}
|
{moveNodes && <MoveDialog nodes={moveNodes} onClose={() => setMoveNodes(null)} onMoved={() => setSelection(new Set())} />}
|
||||||
<FilePreviewDialog
|
<FilePreviewDialog
|
||||||
file={preview}
|
file={preview}
|
||||||
onClose={() => { setPreview(null); setEditTarget(null); setStartInEdit(false); }}
|
onClose={() => { setPreview(null); setEditTarget(null); setStartInEdit(false); }}
|
||||||
@@ -260,17 +371,22 @@ export function FilesView({ nodeId }: { nodeId?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MoveDialog({ node, onClose }: { node: FileNode; onClose: () => void }) {
|
function MoveDialog({ nodes, onClose, onMoved }: { nodes: FileNode[]; onClose: () => void; onMoved: () => void }) {
|
||||||
const files = useFiles();
|
const files = useFiles();
|
||||||
const [cur, setCur] = useState<string | null>(null);
|
const [cur, setCur] = useState<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void files.loadChildren(cur);
|
void files.loadChildren(cur);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [cur]);
|
}, [cur]);
|
||||||
const dirs = (files.children[cur ?? "root"] ?? []).map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n && n.nodeType === "directory" && n.id !== node.id));
|
/* None of the folders being moved can be their own destination, and neither
|
||||||
|
can a folder already holding all of them -- "Move here" would be a no-op. */
|
||||||
|
const moving = new Set(nodes.map((n) => n.id));
|
||||||
|
const dirs = (files.children[cur ?? "root"] ?? []).map((id) => files.nodes[id]).filter((n): n is FileNode => Boolean(n && n.nodeType === "directory" && !moving.has(n.id)));
|
||||||
const path = files.pathTo(cur);
|
const path = files.pathTo(cur);
|
||||||
|
const already = nodes.every((n) => (n.parentId ?? null) === cur);
|
||||||
|
const title = nodes.length === 1 ? t("Move \u201c{name}\u201d", { name: nodes[0]!.name }) : plural(nodes.length, { one: "Move {n} item", other: "Move {n} items" });
|
||||||
return (
|
return (
|
||||||
<Dialog open onClose={onClose} title={t("Move “{name}”", { name: node.name })} size="sm" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={cur === (node.parentId ?? null)} onClick={async () => { try { await files.move(node.id, cur); toast.success(t("Moved")); onClose(); } catch (err) { toast.error((err as Error).message); } }}>{t("Move here")}</button></>}>
|
<Dialog open onClose={onClose} title={title} size="sm" footer={<><button className="btn" onClick={onClose}>{t("Cancel")}</button><button className="btn btn-primary" disabled={already} onClick={async () => { try { await files.moveMany(nodes.map((n) => n.id), cur); toast.success(t("Moved")); onMoved(); onClose(); } catch (err) { toast.error((err as Error).message); } }}>{t("Move here")}</button></>}>
|
||||||
<div className="breadcrumb mb-8">
|
<div className="breadcrumb mb-8">
|
||||||
<button onClick={() => setCur(null)}><Home size={14} /></button>
|
<button onClick={() => setCur(null)}><Home size={14} /></button>
|
||||||
{path.map((n) => <span key={n.id} className="row gap-4"><ChevronRight size={12} /><button onClick={() => setCur(n.id)}>{n.name}</button></span>)}
|
{path.map((n) => <span key={n.id} className="row gap-4"><ChevronRight size={12} /><button onClick={() => setCur(n.id)}>{n.name}</button></span>)}
|
||||||
|
|||||||
Reference in New Issue
Block a user