mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
676 lines
23 KiB
TypeScript
676 lines
23 KiB
TypeScript
import React, { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import Box from "@mui/material/Box";
|
|
import Stack from "@mui/material/Stack";
|
|
import Typography from "@mui/material/Typography";
|
|
import Button from "@mui/material/Button";
|
|
import IconButton from "@mui/material/IconButton";
|
|
import TextField from "@mui/material/TextField";
|
|
import InputAdornment from "@mui/material/InputAdornment";
|
|
import List from "@mui/material/List";
|
|
import ListItemButton from "@mui/material/ListItemButton";
|
|
import ListItemIcon from "@mui/material/ListItemIcon";
|
|
import ListItemText from "@mui/material/ListItemText";
|
|
import Chip from "@mui/material/Chip";
|
|
import AddIcon from "@mui/icons-material/Add";
|
|
import SearchIcon from "@mui/icons-material/Search";
|
|
import CloseIcon from "@mui/icons-material/Close";
|
|
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
|
import EditIcon from "@mui/icons-material/Edit";
|
|
import SettingsIcon from "@mui/icons-material/Settings";
|
|
import LayersIcon from "@mui/icons-material/Layers";
|
|
import { Folder, UnauthorizedError, createFolder, deleteFolder, downloadFolderZip, listFolders, updateFolder, uploadPrints } from "../../../../services/api";
|
|
import { entriesFromDataTransfer, uploadEntriesToFolder } from "../../../../services/uploadTree";
|
|
import { buildUploadEntriesFromZip, isZipFile, readZipEntries } from "../../../../services/zipUtils";
|
|
import { useZipImportPrompt } from "../ZipImportModal";
|
|
import { useImportModePrompt, type ImportMode } from "../ImportModeModal";
|
|
import FolderTreeRow, { type FolderTreeContext } from "./components/FolderTreeRow";
|
|
import FolderActionMenu from "./components/FolderActionMenu";
|
|
import FolderEditorPanel, { type FolderOption } from "./components/FolderEditorPanel";
|
|
|
|
// Every dropped entry's relativePath equals its bare filename when the
|
|
// selection has no folder structure -- that's the signal for the
|
|
// separate-vs-multiplate prompt (a dropped folder always carries nested
|
|
// relativePaths and skips this prompt).
|
|
function isFlatFileSet(entries: { file: File; relativePath: string }[]) {
|
|
return entries.length > 1 && entries.every(entry => entry.relativePath === entry.file.name);
|
|
}
|
|
|
|
type Props = {
|
|
selectedId?: string | null;
|
|
onSelect: (id: string | null) => void;
|
|
onFoldersChanged?: () => void;
|
|
foldersVersion?: number;
|
|
onUnauthorized?: () => void;
|
|
onOpenSettings?: () => void;
|
|
activeView?: "library" | "settings";
|
|
onAssetsChanged?: () => void;
|
|
};
|
|
|
|
const DROP_ALL_ID = "__all";
|
|
|
|
export default function Sidebar({
|
|
selectedId,
|
|
onSelect,
|
|
onFoldersChanged,
|
|
foldersVersion,
|
|
onUnauthorized,
|
|
onOpenSettings,
|
|
activeView = "library",
|
|
onAssetsChanged,
|
|
}: Props) {
|
|
const { t } = useTranslation(["app", "common"]);
|
|
const [folders, setFolders] = useState<Folder[]>([]);
|
|
const [creating, setCreating] = useState(false);
|
|
const [newName, setNewName] = useState("");
|
|
const [newParent, setNewParent] = useState<string | null>(null);
|
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
const [busy, setBusy] = useState(false);
|
|
const [editing, setEditing] = useState<string | null>(null);
|
|
const [editName, setEditName] = useState("");
|
|
const [editTags, setEditTags] = useState<string[]>([]);
|
|
const [editParent, setEditParent] = useState<string | null>(null);
|
|
const [dropTargetId, setDropTargetId] = useState<string | null>(null);
|
|
const [dropUploading, setDropUploading] = useState(false);
|
|
const [query, setQuery] = useState("");
|
|
const [menuState, setMenuState] = useState<{ folderId: string; anchorEl: HTMLElement } | null>(null);
|
|
const zipPrompt = useZipImportPrompt();
|
|
const importModePrompt = useImportModePrompt();
|
|
|
|
const untitledLabel = t("sidebar.untitled");
|
|
|
|
const uploadFlatAsMultiplate = async (files: File[], folderId: string | null) => {
|
|
try {
|
|
const result = await uploadPrints(files, { folder_id: folderId || undefined, mode: "multiplate" });
|
|
return { uploaded: result.prints.length, failed: [] as string[] };
|
|
} catch (err) {
|
|
if (err instanceof UnauthorizedError) {
|
|
onUnauthorized?.();
|
|
return { uploaded: 0, failed: [] as string[] };
|
|
}
|
|
const message = err instanceof Error ? err.message.trim() : "";
|
|
return {
|
|
uploaded: 0,
|
|
failed: [
|
|
message
|
|
? t("sidebar.multiplateImportFailedWithMessage", { message })
|
|
: t("sidebar.multiplateImportFailed"),
|
|
],
|
|
};
|
|
}
|
|
};
|
|
|
|
const uploadWithZipPrompt = async (entries: Awaited<ReturnType<typeof entriesFromDataTransfer>>, folderId: string | null) => {
|
|
const normalEntries = entries.filter(entry => !isZipFile(entry.file.name));
|
|
const zipEntries = entries.filter(entry => isZipFile(entry.file.name));
|
|
let uploaded = 0;
|
|
const failed: string[] = [];
|
|
const applyResult = (result: { uploaded: number; failed: string[] }) => {
|
|
uploaded += result.uploaded;
|
|
failed.push(...result.failed);
|
|
};
|
|
if (normalEntries.length) {
|
|
if (isFlatFileSet(normalEntries)) {
|
|
await importModePrompt.prompt({
|
|
label: normalEntries.map(entry => entry.file.name).join(", "),
|
|
count: normalEntries.length,
|
|
onChoose: async (mode: ImportMode) => {
|
|
if (mode === "multiplate") {
|
|
applyResult(await uploadFlatAsMultiplate(normalEntries.map(entry => entry.file), folderId));
|
|
} else {
|
|
applyResult(await uploadEntriesToFolder(normalEntries, folderId, onUnauthorized));
|
|
}
|
|
},
|
|
});
|
|
} else {
|
|
const result = await uploadEntriesToFolder(normalEntries, folderId, onUnauthorized);
|
|
applyResult(result);
|
|
}
|
|
}
|
|
for (const entry of zipEntries) {
|
|
let zipData: Record<string, Uint8Array> | null = null;
|
|
const baseParts = entry.relativePath.split("/").filter(Boolean);
|
|
baseParts.pop();
|
|
const basePath = baseParts.join("/");
|
|
await zipPrompt.prompt({
|
|
label: entry.file.name,
|
|
onImportAsZip: async () => {
|
|
const result = await uploadEntriesToFolder([entry], folderId, onUnauthorized);
|
|
applyResult(result);
|
|
},
|
|
loadEntries: async () => {
|
|
const result = await readZipEntries(entry.file);
|
|
zipData = result.data;
|
|
return result.entries;
|
|
},
|
|
onImportSelected: async (selectedPaths: string[]) => {
|
|
if (!zipData) {
|
|
const result = await readZipEntries(entry.file);
|
|
zipData = result.data;
|
|
}
|
|
const unzipEntries = buildUploadEntriesFromZip(zipData || {}, selectedPaths, basePath);
|
|
const result = await uploadEntriesToFolder(unzipEntries, folderId, onUnauthorized);
|
|
applyResult(result);
|
|
},
|
|
});
|
|
}
|
|
if (uploaded) {
|
|
onAssetsChanged?.();
|
|
}
|
|
if (failed.length) {
|
|
alert(t("sidebar.errors.uploadFailed", { files: failed.join(", ") }));
|
|
}
|
|
};
|
|
|
|
const isFileDrag = (e: React.DragEvent<HTMLElement>) => {
|
|
const types = Array.from(e.dataTransfer?.types || []);
|
|
return types.includes("Files");
|
|
};
|
|
|
|
const handleDragOverTarget = (targetId: string) => (e: React.DragEvent<HTMLElement>) => {
|
|
if (!isFileDrag(e)) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!dropUploading) {
|
|
setDropTargetId(targetId);
|
|
}
|
|
e.dataTransfer.dropEffect = "copy";
|
|
};
|
|
|
|
const handleDropFiles = (folderId: string | null) => async (
|
|
e: React.DragEvent<HTMLElement>
|
|
) => {
|
|
if (!isFileDrag(e)) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (dropUploading) return;
|
|
setDropTargetId(null);
|
|
setDropUploading(true);
|
|
const entries = await entriesFromDataTransfer(e.dataTransfer);
|
|
if (!entries.length) {
|
|
setDropUploading(false);
|
|
return;
|
|
}
|
|
await uploadWithZipPrompt(entries, folderId);
|
|
setDropUploading(false);
|
|
};
|
|
|
|
const handleSidebarDragOver = (e: React.DragEvent<HTMLElement>) => {
|
|
if (!isFileDrag(e)) return;
|
|
e.preventDefault();
|
|
};
|
|
|
|
const handleSidebarDragLeave = (e: React.DragEvent<HTMLElement>) => {
|
|
if (!isFileDrag(e)) return;
|
|
if (e.currentTarget !== e.target) return;
|
|
e.preventDefault();
|
|
setDropTargetId(null);
|
|
};
|
|
|
|
const handleSidebarDrop = (e: React.DragEvent<HTMLElement>) => {
|
|
if (!isFileDrag(e)) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (dropUploading) return;
|
|
const targetFolderId =
|
|
dropTargetId === DROP_ALL_ID ? null : dropTargetId;
|
|
setDropTargetId(null);
|
|
if (!targetFolderId && dropTargetId !== DROP_ALL_ID) return;
|
|
setDropUploading(true);
|
|
void (async () => {
|
|
const entries = await entriesFromDataTransfer(e.dataTransfer);
|
|
if (!entries.length) {
|
|
setDropUploading(false);
|
|
return;
|
|
}
|
|
await uploadWithZipPrompt(entries, targetFolderId);
|
|
setDropUploading(false);
|
|
})();
|
|
};
|
|
|
|
const filenameFromDisposition = (res: Response, fallback: string) => {
|
|
const dispo = res.headers.get("content-disposition") || "";
|
|
const match = dispo.match(/filename="?([^";]+)"?/i);
|
|
return (match && match[1]) || fallback;
|
|
};
|
|
|
|
const saveResponseToDisk = async (res: Response, fallback: string) => {
|
|
const filename = filenameFromDisposition(res, fallback);
|
|
const blob = await res.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const handleError = (err: unknown, message: string) => {
|
|
if (err instanceof UnauthorizedError) {
|
|
onUnauthorized?.();
|
|
return;
|
|
}
|
|
console.error(err);
|
|
alert(message);
|
|
};
|
|
|
|
const refresh = async () => {
|
|
try {
|
|
setFolders(await listFolders());
|
|
} catch (err) {
|
|
handleError(err, t("sidebar.errors.loadFolders"));
|
|
}
|
|
};
|
|
useEffect(() => { refresh(); }, [foldersVersion]);
|
|
|
|
const startCreate = (parentId: string | null = null) => {
|
|
setEditing(null);
|
|
setCreating(true);
|
|
setNewName("");
|
|
setNewParent(parentId ?? (selectedId || null));
|
|
};
|
|
const create = async () => {
|
|
if (!newName.trim()) return;
|
|
setBusy(true);
|
|
try {
|
|
await createFolder(newName.trim(), [], newParent || undefined);
|
|
await refresh();
|
|
if (newParent) {
|
|
setExpanded(prev => new Set(prev).add(newParent));
|
|
}
|
|
onFoldersChanged?.();
|
|
} catch (err) {
|
|
handleError(err, t("sidebar.errors.createFolder"));
|
|
} finally { setBusy(false); setCreating(false); }
|
|
};
|
|
|
|
const startEdit = (f: Folder) => {
|
|
setCreating(false);
|
|
setEditing(f.id);
|
|
setEditName(f.name);
|
|
setEditTags(f.tags);
|
|
setEditParent(f.parent_id || null);
|
|
};
|
|
const saveEdit = async () => {
|
|
if (!editing) return;
|
|
setBusy(true);
|
|
try {
|
|
await updateFolder(editing, editName.trim() || untitledLabel, editTags, editParent || undefined);
|
|
await refresh();
|
|
onFoldersChanged?.();
|
|
} catch (err) {
|
|
handleError(err, t("sidebar.errors.updateFolder"));
|
|
} finally { setBusy(false); setEditing(null); setEditTags([]); setEditParent(null); }
|
|
};
|
|
|
|
const remove = async (id: string) => {
|
|
if (!confirm(t("sidebar.confirmDeleteFolder"))) return;
|
|
setBusy(true);
|
|
try {
|
|
await deleteFolder(id);
|
|
await refresh();
|
|
onFoldersChanged?.();
|
|
if (selectedId === id) onSelect(null);
|
|
}
|
|
catch (err) {
|
|
handleError(err, t("sidebar.errors.deleteFolder"));
|
|
}
|
|
finally { setBusy(false); }
|
|
};
|
|
|
|
const downloadFolder = async (folder: Folder) => {
|
|
setBusy(true);
|
|
try {
|
|
const res = await downloadFolderZip(folder.id);
|
|
const safe = (folder.name || "folder").replace(/\s+/g, "_") || "folder";
|
|
await saveResponseToDisk(res, `${safe}.zip`);
|
|
} catch (err) {
|
|
handleError(err, t("sidebar.errors.downloadFolder"));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const folderById = React.useMemo(() => {
|
|
const map: Record<string, Folder> = {};
|
|
folders.forEach(f => { map[f.id] = f; });
|
|
return map;
|
|
}, [folders]);
|
|
|
|
useEffect(() => {
|
|
// Ensure selected folder path is expanded
|
|
if (!selectedId) return;
|
|
const next = new Set(expanded);
|
|
let current = folderById[selectedId];
|
|
const guard = new Set<string>();
|
|
while (current?.parent_id && !guard.has(current.parent_id)) {
|
|
next.add(current.parent_id);
|
|
guard.add(current.parent_id);
|
|
current = folderById[current.parent_id];
|
|
}
|
|
setExpanded(next);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedId, folderById]);
|
|
|
|
const isDescendant = React.useCallback(
|
|
(candidateId: string, targetId: string) => {
|
|
let current = folderById[candidateId];
|
|
const guard = new Set<string>();
|
|
while (current) {
|
|
if (!current.parent_id) return false;
|
|
if (current.parent_id === targetId) return true;
|
|
if (guard.has(current.parent_id)) break;
|
|
guard.add(current.parent_id);
|
|
current = folderById[current.parent_id];
|
|
}
|
|
return false;
|
|
},
|
|
[folderById]
|
|
);
|
|
|
|
const folderPath = React.useCallback(
|
|
(folder: Folder) => {
|
|
const segments = [folder.name || untitledLabel];
|
|
let current = folder;
|
|
const guard = new Set<string>([folder.id]);
|
|
while (current.parent_id) {
|
|
const parent = folderById[current.parent_id];
|
|
if (!parent || guard.has(parent.id)) break;
|
|
segments.unshift(parent.name || untitledLabel);
|
|
guard.add(parent.id);
|
|
current = parent;
|
|
}
|
|
return segments.join(" / ");
|
|
},
|
|
[folderById, untitledLabel]
|
|
);
|
|
|
|
const folderOptions: FolderOption[] = React.useMemo(() => {
|
|
const opts: FolderOption[] = [{ id: null, name: t("sidebar.rootOption") }];
|
|
const sorted = [...folders].sort((a, b) => folderPath(a).localeCompare(folderPath(b)));
|
|
sorted.forEach(f => opts.push({ id: f.id, name: folderPath(f) }));
|
|
return opts;
|
|
}, [folders, folderPath, t]);
|
|
|
|
const childrenMap = React.useMemo(() => {
|
|
const map: Record<string, Folder[]> = {};
|
|
const push = (key: string, f: Folder) => {
|
|
if (!map[key]) map[key] = [];
|
|
map[key].push(f);
|
|
};
|
|
folders.forEach(f => push(f.parent_id || "__root", f));
|
|
Object.values(map).forEach(list => list.sort((a, b) => a.name.localeCompare(b.name)));
|
|
return map;
|
|
}, [folders]);
|
|
|
|
const visibleFolderIds = React.useMemo(() => {
|
|
const normalizedQuery = query.trim().toLocaleLowerCase();
|
|
if (!normalizedQuery) return null;
|
|
|
|
const visible = new Set<string>();
|
|
folders.forEach(folder => {
|
|
if (!folderPath(folder).toLocaleLowerCase().includes(normalizedQuery)) return;
|
|
let current: Folder | undefined = folder;
|
|
const guard = new Set<string>();
|
|
while (current && !guard.has(current.id)) {
|
|
visible.add(current.id);
|
|
guard.add(current.id);
|
|
current = current.parent_id ? folderById[current.parent_id] : undefined;
|
|
}
|
|
});
|
|
return visible;
|
|
}, [folderById, folderPath, folders, query]);
|
|
|
|
// Default-expand root folders so they are visible
|
|
useEffect(() => {
|
|
const roots = childrenMap["__root"] || [];
|
|
if (!roots.length) return;
|
|
setExpanded(prev => {
|
|
const next = new Set(prev);
|
|
roots.forEach(r => next.add(r.id));
|
|
return next;
|
|
});
|
|
}, [childrenMap]);
|
|
|
|
const toggleExpand = (id: string) => {
|
|
setExpanded(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const activeMenuFolder = menuState ? folderById[menuState.folderId] ?? null : null;
|
|
|
|
const treeCtx: FolderTreeContext = {
|
|
childrenMap,
|
|
visibleFolderIds,
|
|
expanded,
|
|
dropTargetId,
|
|
selectedId,
|
|
query,
|
|
untitledLabel,
|
|
collapseLabel: t("sidebar.collapse"),
|
|
expandLabel: t("sidebar.expand"),
|
|
actionsForLabel: name => t("sidebar.actionsFor", { name }),
|
|
folderPath,
|
|
onSelect: id => onSelect(id),
|
|
onToggleExpand: toggleExpand,
|
|
onDragOverTarget: handleDragOverTarget,
|
|
onDropFiles: handleDropFiles,
|
|
onOpenMenu: (folderId, anchorEl) => setMenuState({ folderId, anchorEl }),
|
|
};
|
|
|
|
const editFolderOptions = React.useMemo(
|
|
() =>
|
|
folderOptions.filter(
|
|
opt => !editing || (opt.id !== editing && !(opt.id && isDescendant(opt.id, editing)))
|
|
),
|
|
[folderOptions, editing, isDescendant]
|
|
);
|
|
|
|
return (
|
|
<Box
|
|
component="aside"
|
|
onDragOver={handleSidebarDragOver}
|
|
onDragLeave={handleSidebarDragLeave}
|
|
onDrop={handleSidebarDrop}
|
|
sx={{
|
|
width: 280,
|
|
flexShrink: 0,
|
|
height: "100vh",
|
|
position: "sticky",
|
|
top: 0,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
borderRight: "1px solid",
|
|
borderColor: "divider",
|
|
bgcolor: "background.paper",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, pt: 2, pb: 1 }}>
|
|
<Box sx={{ minWidth: 0 }}>
|
|
<Typography variant="caption" color="text.secondary" sx={{ textTransform: "uppercase", letterSpacing: 0.5 }}>
|
|
{t("sidebar.brand")}
|
|
</Typography>
|
|
<Stack direction="row" alignItems="center" spacing={1}>
|
|
<Typography variant="h6" noWrap>{t("sidebar.libraryTitle")}</Typography>
|
|
<Chip size="small" label={folders.length} title={t("sidebar.folderCountTitle", { count: folders.length }) ?? undefined} />
|
|
</Stack>
|
|
</Box>
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
startIcon={<AddIcon fontSize="small" />}
|
|
onClick={() => startCreate(null)}
|
|
title={t("sidebar.newFolderTitle") ?? undefined}
|
|
>
|
|
{t("sidebar.newFolder")}
|
|
</Button>
|
|
</Stack>
|
|
|
|
<Box sx={{ px: 2, pb: 1 }}>
|
|
<TextField
|
|
fullWidth
|
|
size="small"
|
|
value={query}
|
|
onChange={event => setQuery(event.target.value)}
|
|
placeholder={t("sidebar.searchPlaceholder") ?? undefined}
|
|
inputProps={{ "aria-label": t("sidebar.searchAriaLabel") ?? undefined }}
|
|
InputProps={{
|
|
startAdornment: (
|
|
<InputAdornment position="start">
|
|
<SearchIcon fontSize="small" />
|
|
</InputAdornment>
|
|
),
|
|
endAdornment: query ? (
|
|
<InputAdornment position="end">
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => setQuery("")}
|
|
aria-label={t("sidebar.clearSearchAria") ?? undefined}
|
|
>
|
|
<CloseIcon fontSize="small" />
|
|
</IconButton>
|
|
</InputAdornment>
|
|
) : undefined,
|
|
}}
|
|
/>
|
|
</Box>
|
|
|
|
<Box component="nav" aria-label={t("sidebar.foldersHeading") ?? undefined} sx={{ flex: 1, overflow: "auto", px: 1 }}>
|
|
<List disablePadding>
|
|
<ListItemButton
|
|
selected={!selectedId}
|
|
onClick={() => onSelect(null)}
|
|
onDragOver={handleDragOverTarget(DROP_ALL_ID)}
|
|
onDrop={handleDropFiles(null)}
|
|
sx={{
|
|
borderRadius: 1,
|
|
mb: 0.5,
|
|
...(dropTargetId === DROP_ALL_ID && {
|
|
outline: "2px dashed",
|
|
outlineColor: "primary.main",
|
|
outlineOffset: -2,
|
|
}),
|
|
}}
|
|
>
|
|
<ListItemIcon sx={{ minWidth: 30 }}>
|
|
<LayersIcon fontSize="small" />
|
|
</ListItemIcon>
|
|
<ListItemText primary={t("sidebar.allFiles")} primaryTypographyProps={{ variant: "body2" }} />
|
|
<Chip size="small" variant="outlined" label={t("sidebar.root")} />
|
|
</ListItemButton>
|
|
</List>
|
|
|
|
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 1, py: 0.75 }}>
|
|
<Typography variant="caption" color="text.secondary" sx={{ textTransform: "uppercase", letterSpacing: 0.5 }}>
|
|
{t("sidebar.foldersHeading")}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{visibleFolderIds ? visibleFolderIds.size : folders.length}
|
|
</Typography>
|
|
</Stack>
|
|
|
|
<List disablePadding>
|
|
{(childrenMap["__root"] || []).map(f => (
|
|
<FolderTreeRow key={f.id} folder={f} depth={0} ctx={treeCtx} />
|
|
))}
|
|
</List>
|
|
{folders.length === 0 && (
|
|
<Stack alignItems="center" spacing={1} sx={{ py: 4, color: "text.secondary" }}>
|
|
<CreateNewFolderIcon />
|
|
<Typography variant="body2">{t("sidebar.noFoldersYet")}</Typography>
|
|
<Button size="small" onClick={() => startCreate(null)}>{t("sidebar.createFirstFolder")}</Button>
|
|
</Stack>
|
|
)}
|
|
{!!folders.length && visibleFolderIds?.size === 0 && (
|
|
<Stack alignItems="center" spacing={1} sx={{ py: 4, color: "text.secondary" }}>
|
|
<SearchIcon />
|
|
<Typography variant="body2">{t("sidebar.noMatchingFolders")}</Typography>
|
|
<Button size="small" onClick={() => setQuery("")}>{t("sidebar.clearSearch")}</Button>
|
|
</Stack>
|
|
)}
|
|
</Box>
|
|
|
|
<FolderActionMenu
|
|
folder={activeMenuFolder}
|
|
anchorEl={menuState?.anchorEl ?? null}
|
|
busy={busy}
|
|
labels={{
|
|
subfolder: t("sidebar.subfolder"),
|
|
renameEdit: t("sidebar.renameEdit"),
|
|
downloadZip: t("sidebar.downloadZip"),
|
|
delete: t("sidebar.delete"),
|
|
}}
|
|
onClose={() => setMenuState(null)}
|
|
onSubfolder={folder => startCreate(folder.id)}
|
|
onRename={startEdit}
|
|
onDownload={downloadFolder}
|
|
onDelete={folder => remove(folder.id)}
|
|
/>
|
|
|
|
{creating && (
|
|
<FolderEditorPanel
|
|
mode="create"
|
|
icon={<CreateNewFolderIcon fontSize="small" />}
|
|
title={t("sidebar.createFolderTitle")}
|
|
subtitle={t("sidebar.createFolderSubtitle")}
|
|
name={newName}
|
|
onNameChange={setNewName}
|
|
namePlaceholder={t("sidebar.folderNamePlaceholder") ?? undefined}
|
|
parentId={newParent}
|
|
onParentChange={setNewParent}
|
|
folderOptions={folderOptions}
|
|
busy={busy}
|
|
submitLabel={t("sidebar.create")}
|
|
submitDisabled={!newName.trim()}
|
|
onSubmit={create}
|
|
onCancel={() => { setCreating(false); setNewParent(null); }}
|
|
/>
|
|
)}
|
|
|
|
{editing && (
|
|
<FolderEditorPanel
|
|
mode="edit"
|
|
icon={<EditIcon fontSize="small" />}
|
|
title={t("sidebar.editFolderTitle")}
|
|
subtitle={t("sidebar.editFolderSubtitle")}
|
|
name={editName}
|
|
onNameChange={setEditName}
|
|
parentId={editParent}
|
|
onParentChange={setEditParent}
|
|
folderOptions={editFolderOptions}
|
|
tags={editTags}
|
|
onTagsChange={setEditTags}
|
|
tagsPlaceholder={t("sidebar.addFolderTagsPlaceholder") ?? undefined}
|
|
busy={busy}
|
|
submitLabel={t("sidebar.saveChanges")}
|
|
onSubmit={saveEdit}
|
|
onCancel={() => { setEditing(null); setEditTags([]); setEditParent(null); }}
|
|
/>
|
|
)}
|
|
|
|
<Box sx={{ borderTop: "1px solid", borderColor: "divider", p: 1 }}>
|
|
<ListItemButton
|
|
selected={activeView === "settings"}
|
|
onClick={onOpenSettings}
|
|
aria-label={t("common:settings") ?? undefined}
|
|
sx={{ borderRadius: 1 }}
|
|
>
|
|
<ListItemIcon sx={{ minWidth: 30 }}>
|
|
<SettingsIcon fontSize="small" />
|
|
</ListItemIcon>
|
|
<ListItemText primary={t("common:settings")} primaryTypographyProps={{ variant: "body2" }} />
|
|
</ListItemButton>
|
|
</Box>
|
|
{zipPrompt.modal}
|
|
{importModePrompt.modal}
|
|
</Box>
|
|
);
|
|
}
|