initial commit after split
This commit is contained in:
201
webui/src/api/files.ts
Normal file
201
webui/src/api/files.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { apiFetch, apiUrl, authHeaders, csrfToken, type ApiSettings } from "@govoplan/core-webui";
|
||||
|
||||
export type FileSpace = {
|
||||
id: string;
|
||||
label: string;
|
||||
owner_type: "user" | "group";
|
||||
owner_id: string;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type FileShare = {
|
||||
id: string;
|
||||
target_type: string;
|
||||
target_id: string;
|
||||
permission: string;
|
||||
created_at: string;
|
||||
revoked_at?: string | null;
|
||||
};
|
||||
|
||||
export type ManagedFile = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
owner_type: "user" | "group";
|
||||
owner_id: string;
|
||||
display_path: string;
|
||||
filename: string;
|
||||
description?: string | null;
|
||||
size_bytes: number;
|
||||
content_type?: string | null;
|
||||
checksum_sha256: string;
|
||||
version_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at?: string | null;
|
||||
audit_relevant: boolean;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
shares?: FileShare[];
|
||||
};
|
||||
|
||||
export type FileListResponse = { files: ManagedFile[] };
|
||||
export type FileSpacesResponse = { spaces: FileSpace[] };
|
||||
export type FileUploadResponse = { files: ManagedFile[] };
|
||||
export type FileFolder = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
owner_type: "user" | "group";
|
||||
owner_id: string;
|
||||
path: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at?: string | null;
|
||||
};
|
||||
export type FileFoldersResponse = { folders: FileFolder[] };
|
||||
export type FolderDeleteResponse = { deleted_folders: number; deleted_files: number };
|
||||
export type BulkDeleteResponse = { deleted_count: number };
|
||||
export type RenameResponse = { dry_run: boolean; items: { kind: "file" | "folder"; id: string; file_id?: string | null; folder_path?: string | null; old_path: string; new_path: string }[] };
|
||||
export type TransferResponse = { operation: "move" | "copy"; files: number; folders: number };
|
||||
export type ConflictAction = "overwrite" | "rename" | "skip";
|
||||
export type ConflictStrategy = "reject" | "overwrite" | "rename";
|
||||
export type ConflictResolution = { target_path: string; action: ConflictAction; new_path?: string };
|
||||
export type PatternResolveResponse = {
|
||||
patterns: { pattern: string; matches: ManagedFile[] }[];
|
||||
unmatched: ManagedFile[];
|
||||
};
|
||||
|
||||
|
||||
export function listFileSpaces(settings: ApiSettings): Promise<FileSpacesResponse> {
|
||||
return apiFetch<FileSpacesResponse>(settings, "/api/v1/files/spaces");
|
||||
}
|
||||
|
||||
|
||||
export function listFolders(settings: ApiSettings, params: { owner_type: "user" | "group"; owner_id: string }): Promise<FileFoldersResponse> {
|
||||
const search = new URLSearchParams();
|
||||
search.set("owner_type", params.owner_type);
|
||||
search.set("owner_id", params.owner_id);
|
||||
return apiFetch<FileFoldersResponse>(settings, `/api/v1/files/folders?${search.toString()}`);
|
||||
}
|
||||
|
||||
export function createFolder(
|
||||
settings: ApiSettings,
|
||||
payload: { owner_type: "user" | "group"; owner_id: string; path: string }
|
||||
): Promise<FileFolder> {
|
||||
return apiFetch<FileFolder>(settings, "/api/v1/files/folders", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function deleteFolder(
|
||||
settings: ApiSettings,
|
||||
payload: { owner_type: "user" | "group"; owner_id: string; path: string; recursive?: boolean }
|
||||
): Promise<FolderDeleteResponse> {
|
||||
return apiFetch<FolderDeleteResponse>(settings, "/api/v1/files/folders/delete", { method: "POST", body: JSON.stringify({ recursive: true, ...payload }) });
|
||||
}
|
||||
|
||||
export function listFiles(settings: ApiSettings, params: { owner_type?: string; owner_id?: string; campaign_id?: string; path_prefix?: string } = {}): Promise<FileListResponse> {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value) search.set(key, value);
|
||||
}
|
||||
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||
return apiFetch<FileListResponse>(settings, `/api/v1/files${suffix}`);
|
||||
}
|
||||
|
||||
export async function uploadFiles(
|
||||
settings: ApiSettings,
|
||||
files: File[],
|
||||
options: { owner_type: "user" | "group"; owner_id: string; path?: string; campaign_id?: string; unpack_zip?: boolean; conflict_strategy?: ConflictStrategy; conflict_resolutions?: ConflictResolution[] }
|
||||
): Promise<FileUploadResponse> {
|
||||
const form = new FormData();
|
||||
files.forEach((file) => form.append("files", file));
|
||||
form.append("owner_type", options.owner_type);
|
||||
form.append("owner_id", options.owner_id);
|
||||
form.append("path", options.path ?? "");
|
||||
if (options.campaign_id) form.append("campaign_id", options.campaign_id);
|
||||
if (options.unpack_zip) form.append("unpack_zip", "true");
|
||||
if (options.conflict_strategy) form.append("conflict_strategy", options.conflict_strategy);
|
||||
if (options.conflict_resolutions?.length) form.append("conflict_resolutions_json", JSON.stringify(options.conflict_resolutions));
|
||||
return apiFetch<FileUploadResponse>(settings, "/api/v1/files/upload", { method: "POST", body: form });
|
||||
}
|
||||
|
||||
export function deleteFile(settings: ApiSettings, fileId: string): Promise<BulkDeleteResponse> {
|
||||
return apiFetch<BulkDeleteResponse>(settings, `/api/v1/files/${fileId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function bulkDeleteFiles(settings: ApiSettings, fileIds: string[]): Promise<BulkDeleteResponse> {
|
||||
return apiFetch<BulkDeleteResponse>(settings, "/api/v1/files/bulk-delete", { method: "POST", body: JSON.stringify({ file_ids: fileIds }) });
|
||||
}
|
||||
|
||||
export function shareFileWithCampaign(settings: ApiSettings, fileId: string, campaignId: string): Promise<FileShare> {
|
||||
return apiFetch<FileShare>(settings, `/api/v1/files/${fileId}/shares`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ target_type: "campaign", target_id: campaignId, permission: "read" })
|
||||
});
|
||||
}
|
||||
|
||||
export function bulkRenameFiles(
|
||||
settings: ApiSettings,
|
||||
payload: { file_ids: string[]; folder_paths?: string[]; owner_type?: "user" | "group"; owner_id?: string; mode: "direct" | "prefix" | "suffix" | "replace"; new_name?: string; find?: string; replacement?: string; prefix?: string; suffix?: string; recursive?: boolean; dry_run?: boolean }
|
||||
): Promise<RenameResponse> {
|
||||
return apiFetch<RenameResponse>(settings, "/api/v1/files/bulk-rename", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ replacement: "", prefix: "", suffix: "", dry_run: true, ...payload })
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveFilePatterns(
|
||||
settings: ApiSettings,
|
||||
payload: { patterns: string[]; owner_type?: "user" | "group"; owner_id?: string; campaign_id?: string; path_prefix?: string; include_unmatched?: boolean; case_sensitive?: boolean }
|
||||
): Promise<PatternResolveResponse> {
|
||||
return apiFetch<PatternResolveResponse>(settings, "/api/v1/files/resolve-patterns", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function transferFiles(
|
||||
settings: ApiSettings,
|
||||
payload: {
|
||||
operation: "move" | "copy";
|
||||
file_ids: string[];
|
||||
folder_paths: string[];
|
||||
source_owner_type: "user" | "group";
|
||||
source_owner_id: string;
|
||||
target_owner_type: "user" | "group";
|
||||
target_owner_id: string;
|
||||
target_folder: string;
|
||||
conflict_strategy?: ConflictStrategy;
|
||||
conflict_resolutions?: ConflictResolution[];
|
||||
}
|
||||
): Promise<TransferResponse> {
|
||||
return apiFetch<TransferResponse>(settings, "/api/v1/files/transfer", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export async function downloadFile(settings: ApiSettings, file: ManagedFile): Promise<void> {
|
||||
const response = await fetch(apiUrl(settings, `/api/v1/files/${file.id}/download`), { headers: authHeaders(settings), credentials: "include" });
|
||||
if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
|
||||
const blob = await response.blob();
|
||||
triggerDownload(blob, file.filename);
|
||||
}
|
||||
|
||||
export async function downloadFilesAsZip(settings: ApiSettings, fileIds: string[], filename = "files.zip"): Promise<void> {
|
||||
const headers = authHeaders(settings);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const csrf = csrfToken();
|
||||
if (csrf) headers.set("X-CSRF-Token", csrf);
|
||||
const response = await fetch(apiUrl(settings, "/api/v1/files/archive.zip"), {
|
||||
method: "POST",
|
||||
headers,
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ file_ids: fileIds, filename })
|
||||
});
|
||||
if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
|
||||
const blob = await response.blob();
|
||||
triggerDownload(blob, filename);
|
||||
}
|
||||
|
||||
function triggerDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
1529
webui/src/features/files/FilesPage.tsx
Normal file
1529
webui/src/features/files/FilesPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
380
webui/src/features/files/components/FileManagerComponents.tsx
Normal file
380
webui/src/features/files/components/FileManagerComponents.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
import type { CSSProperties, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
|
||||
import { Copy, Download, Folder, FolderOpen, Home, MoveRight, Plus, Trash2, UploadCloud } from "lucide-react";
|
||||
import { Button, Dialog } from "@govoplan/core-webui";
|
||||
import type { ConflictAction, FileSpace, RenameResponse } from "../../../api/files";
|
||||
import type { ConflictDialogState, FileActionTarget, FileConflictItem, FolderNode, ContextMenuState } from "../types";
|
||||
import { isPathUnderOrSame, normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils";
|
||||
|
||||
export function TransferFolderSelector({
|
||||
space,
|
||||
nodes,
|
||||
selectedFolder,
|
||||
onSelect,
|
||||
disabled
|
||||
}: {
|
||||
space: FileSpace | null;
|
||||
nodes: FolderNode[];
|
||||
selectedFolder: string;
|
||||
onSelect: (folderPath: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="file-folder-selector" role="tree" aria-label="Destination folder">
|
||||
<button
|
||||
type="button"
|
||||
className={`file-folder-selector-node ${selectedFolder === "" ? "is-selected" : ""}`}
|
||||
onClick={() => onSelect("")}
|
||||
disabled={disabled || !space}
|
||||
>
|
||||
<Home size={15} aria-hidden="true" />
|
||||
<span>{space?.label || "Root"}</span>
|
||||
</button>
|
||||
{nodes.length === 0 && <span className="muted small-text file-folder-selector-empty">No folders yet. Choose the root folder.</span>}
|
||||
<TransferFolderSelectorNodes nodes={nodes} selectedFolder={selectedFolder} onSelect={onSelect} disabled={disabled} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TransferFolderSelectorNodes({
|
||||
nodes,
|
||||
selectedFolder,
|
||||
onSelect,
|
||||
disabled,
|
||||
depth = 1
|
||||
}: {
|
||||
nodes: FolderNode[];
|
||||
selectedFolder: string;
|
||||
onSelect: (folderPath: string) => void;
|
||||
disabled?: boolean;
|
||||
depth?: number;
|
||||
}) {
|
||||
if (nodes.length === 0) return null;
|
||||
return (
|
||||
<div className="file-folder-selector-children">
|
||||
{nodes.map((node) => (
|
||||
<div key={node.path}>
|
||||
<button
|
||||
type="button"
|
||||
className={`file-folder-selector-node ${selectedFolder === node.path ? "is-selected" : ""}`}
|
||||
style={{ paddingLeft: `${Math.min(depth * 16 + 10, 74)}px` }}
|
||||
onClick={() => onSelect(node.path)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FolderOpen size={15} aria-hidden="true" />
|
||||
<span>{node.name}</span>
|
||||
</button>
|
||||
<TransferFolderSelectorNodes nodes={node.children} selectedFolder={selectedFolder} onSelect={onSelect} disabled={disabled} depth={depth + 1} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FolderTree({
|
||||
nodes,
|
||||
activeSpaceId,
|
||||
spaceId,
|
||||
currentFolder,
|
||||
dropTargetKey = "",
|
||||
expandedKeys,
|
||||
onOpen,
|
||||
onToggle,
|
||||
onContextMenu,
|
||||
onDragOverTarget,
|
||||
onDropOnTarget,
|
||||
onClearDropState,
|
||||
onRequestDragExpand,
|
||||
onDragStartFolder,
|
||||
onDragEndFolder,
|
||||
dragDropEnabled = true,
|
||||
contextMenuEnabled = true,
|
||||
disabled,
|
||||
depth = 1
|
||||
}: {
|
||||
nodes: FolderNode[];
|
||||
activeSpaceId: string;
|
||||
spaceId: string;
|
||||
currentFolder: string;
|
||||
dropTargetKey?: string;
|
||||
expandedKeys: Set<string>;
|
||||
onOpen: (spaceId: string, path: string) => void;
|
||||
onToggle: (spaceId: string, path: string) => void;
|
||||
onContextMenu?: (event: ReactMouseEvent<HTMLElement>, spaceId: string, path: string) => void;
|
||||
onDragOverTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => void;
|
||||
onDropOnTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => Promise<void>;
|
||||
onClearDropState?: () => void;
|
||||
onRequestDragExpand?: (spaceId: string, path: string) => void;
|
||||
onDragStartFolder?: (spaceId: string, path: string, event: ReactDragEvent<HTMLElement>) => void;
|
||||
onDragEndFolder?: () => void;
|
||||
dragDropEnabled?: boolean;
|
||||
contextMenuEnabled?: boolean;
|
||||
disabled?: boolean;
|
||||
depth?: number;
|
||||
}) {
|
||||
if (nodes.length === 0) return null;
|
||||
return (
|
||||
<div className="file-tree-children">
|
||||
{nodes.map((node) => {
|
||||
const isActive = activeSpaceId === spaceId && currentFolder === node.path;
|
||||
const target = { spaceId, folderPath: node.path };
|
||||
const isDropTarget = dragDropEnabled && dropTargetKey === `${target.spaceId}:${normalizeFolder(target.folderPath)}`;
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = expandedKeys.has(treeNodeKey(spaceId, node.path));
|
||||
return (
|
||||
<div key={node.path}>
|
||||
<div
|
||||
className={`file-tree-node-wrap ${isActive ? "is-active" : ""} ${isDropTarget ? "is-drop-target" : ""}`}
|
||||
style={{ paddingLeft: `${Math.min(depth * 14, 56)}px` }}
|
||||
draggable={dragDropEnabled && !disabled}
|
||||
onDragStart={dragDropEnabled && onDragStartFolder ? (event) => onDragStartFolder(spaceId, node.path, event) : undefined}
|
||||
onDragEnd={dragDropEnabled ? onDragEndFolder : undefined}
|
||||
onContextMenu={contextMenuEnabled && onContextMenu ? (event) => onContextMenu(event, spaceId, node.path) : undefined}
|
||||
onDragOver={dragDropEnabled && onDragOverTarget ? (event) => {
|
||||
onDragOverTarget(event, target);
|
||||
if (hasChildren && !isExpanded) onRequestDragExpand?.(spaceId, node.path);
|
||||
} : undefined}
|
||||
onDragLeave={dragDropEnabled ? onClearDropState : undefined}
|
||||
onDrop={dragDropEnabled && onDropOnTarget ? (event) => void onDropOnTarget(event, target) : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="file-tree-toggle"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (hasChildren) onToggle(spaceId, node.path);
|
||||
}}
|
||||
disabled={disabled || !hasChildren}
|
||||
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${node.name}`}
|
||||
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||
>
|
||||
{isExpanded ? <FolderOpen size={15} aria-hidden="true" /> : <Folder size={15} aria-hidden="true" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="file-tree-node"
|
||||
onClick={() => onOpen(spaceId, node.path)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span>{node.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
{hasChildren && isExpanded && (
|
||||
<FolderTree
|
||||
nodes={node.children}
|
||||
activeSpaceId={activeSpaceId}
|
||||
spaceId={spaceId}
|
||||
currentFolder={currentFolder}
|
||||
dropTargetKey={dropTargetKey}
|
||||
expandedKeys={expandedKeys}
|
||||
onOpen={onOpen}
|
||||
onToggle={onToggle}
|
||||
onContextMenu={onContextMenu}
|
||||
onDragOverTarget={onDragOverTarget}
|
||||
onDropOnTarget={onDropOnTarget}
|
||||
onClearDropState={onClearDropState}
|
||||
onRequestDragExpand={onRequestDragExpand}
|
||||
onDragStartFolder={onDragStartFolder}
|
||||
onDragEndFolder={onDragEndFolder}
|
||||
dragDropEnabled={dragDropEnabled}
|
||||
contextMenuEnabled={contextMenuEnabled}
|
||||
disabled={disabled}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RenamePreviewList({
|
||||
response,
|
||||
selectedFileIds,
|
||||
selectedFolderPaths,
|
||||
recursive,
|
||||
visibleCount,
|
||||
onShowMore,
|
||||
onShowFewer
|
||||
}: {
|
||||
response: RenameResponse;
|
||||
selectedFileIds: Set<string>;
|
||||
selectedFolderPaths: Set<string>;
|
||||
recursive: boolean;
|
||||
visibleCount: number;
|
||||
onShowMore: () => void;
|
||||
onShowFewer: () => void;
|
||||
}) {
|
||||
const selectedFolderRoots = Array.from(selectedFolderPaths).map(normalizeFolder);
|
||||
const hiddenNonRecursiveItems = !recursive
|
||||
? response.items.filter((item) => {
|
||||
if (item.kind === "file" && selectedFileIds.has(item.id)) return false;
|
||||
if (item.kind === "folder" && selectedFolderRoots.includes(normalizeFolder(item.old_path))) return false;
|
||||
return selectedFolderRoots.some((folder) => isPathUnderOrSame(item.old_path, folder));
|
||||
}).length
|
||||
: 0;
|
||||
const visibleItems = response.items.filter((item) => {
|
||||
if (recursive) return true;
|
||||
if (item.kind === "file") {
|
||||
if (selectedFileIds.has(item.id)) return true;
|
||||
return !selectedFolderRoots.some((folder) => isPathUnderOrSame(item.old_path, folder));
|
||||
}
|
||||
return selectedFolderRoots.includes(normalizeFolder(item.old_path));
|
||||
});
|
||||
const safeVisibleCount = Math.max(20, visibleCount);
|
||||
const shownItems = visibleItems.slice(0, safeVisibleCount);
|
||||
const remaining = visibleItems.length - shownItems.length;
|
||||
return (
|
||||
<div className="rename-preview-panel">
|
||||
<div className="placeholder-stack rename-preview-list">
|
||||
{hiddenNonRecursiveItems > 0 && <span className="muted">{hiddenNonRecursiveItems} contained path(s) will move with the selected folder(s), but their own names will stay unchanged.</span>}
|
||||
{shownItems.map((item) => <span key={`${item.kind}:${item.id}:${item.old_path}`}><code>{item.old_path}</code> → <code>{item.new_path}</code></span>)}
|
||||
{remaining > 0 && (
|
||||
<button type="button" className="rename-preview-more" onClick={onShowMore}>… and {remaining} more — show next {Math.min(20, remaining)}</button>
|
||||
)}
|
||||
{shownItems.length > 20 && (
|
||||
<button type="button" className="rename-preview-more" onClick={onShowFewer}>Show fewer</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileDialog({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
backdropClassName="file-dialog-backdrop"
|
||||
className="file-dialog"
|
||||
headerClassName="file-dialog-header"
|
||||
titleClassName="file-dialog-title"
|
||||
bodyClassName="file-dialog-body"
|
||||
>
|
||||
{children}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileContextMenu({
|
||||
menu,
|
||||
hasSelection,
|
||||
canCreateFolder,
|
||||
canUpload,
|
||||
canDownload,
|
||||
canOrganize,
|
||||
canDelete,
|
||||
downloadLabel,
|
||||
onCreateFolder,
|
||||
onUpload,
|
||||
onDownload,
|
||||
onMove,
|
||||
onCopy,
|
||||
onDelete
|
||||
}: {
|
||||
menu: ContextMenuState;
|
||||
hasSelection: boolean;
|
||||
canCreateFolder: boolean;
|
||||
canUpload: boolean;
|
||||
canDownload: boolean;
|
||||
canOrganize: boolean;
|
||||
canDelete: boolean;
|
||||
downloadLabel: string;
|
||||
onCreateFolder: () => void;
|
||||
onUpload: () => void;
|
||||
onDownload: () => void;
|
||||
onMove: () => void;
|
||||
onCopy: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const showNewFolder = true;
|
||||
const showDelete = menu.target !== "empty";
|
||||
const viewportWidth = typeof window === "undefined" ? 1024 : window.innerWidth;
|
||||
const viewportHeight = typeof window === "undefined" ? 768 : window.innerHeight;
|
||||
const estimatedWidth = 220;
|
||||
const estimatedHeight = 260;
|
||||
const left = Math.max(8, Math.min(menu.x, viewportWidth - estimatedWidth - 8));
|
||||
const openUp = menu.y + estimatedHeight > viewportHeight;
|
||||
const style: CSSProperties = openUp
|
||||
? { left, bottom: Math.max(8, viewportHeight - menu.y) }
|
||||
: { left, top: Math.max(8, menu.y) };
|
||||
return (
|
||||
<div className="file-context-menu" style={style} role="menu" onClick={(event) => event.stopPropagation()}>
|
||||
{showNewFolder && <button type="button" role="menuitem" onClick={onCreateFolder} disabled={!canCreateFolder}><Plus size={15} aria-hidden="true" /> New folder</button>}
|
||||
<button type="button" role="menuitem" onClick={onUpload} disabled={!canUpload}><UploadCloud size={15} aria-hidden="true" /> Upload</button>
|
||||
<button type="button" role="menuitem" onClick={onDownload} disabled={!hasSelection || !canDownload}><Download size={15} aria-hidden="true" /> {downloadLabel}</button>
|
||||
<button type="button" role="menuitem" onClick={onMove} disabled={!hasSelection || !canOrganize}><MoveRight size={15} aria-hidden="true" /> Move…</button>
|
||||
<button type="button" role="menuitem" onClick={onCopy} disabled={!hasSelection || !canOrganize}><Copy size={15} aria-hidden="true" /> Copy…</button>
|
||||
{showDelete && <button type="button" role="menuitem" className="danger" onClick={onDelete} disabled={!canDelete}><Trash2 size={15} aria-hidden="true" /> Delete</button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileConflictDialog({
|
||||
state,
|
||||
busy,
|
||||
onClose,
|
||||
onCancel,
|
||||
onOverwrite,
|
||||
onRename,
|
||||
onReview,
|
||||
onApplyReview,
|
||||
onUpdateItem
|
||||
}: {
|
||||
state: ConflictDialogState;
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onCancel: () => void;
|
||||
onOverwrite: () => void;
|
||||
onRename: () => void;
|
||||
onReview: () => void;
|
||||
onApplyReview: () => void;
|
||||
onUpdateItem: (id: string, patch: Partial<FileConflictItem>) => void;
|
||||
}) {
|
||||
return (
|
||||
<FileDialog title={state.title} onClose={onClose}>
|
||||
<p className="muted">{state.message}</p>
|
||||
<div className="file-conflict-summary">
|
||||
<strong>{state.items.length} conflict(s)</strong>
|
||||
<span>{state.items.length > 1 ? "Choose the same action for all conflicts or review the target names individually." : "Choose how to resolve this conflict."}</span>
|
||||
</div>
|
||||
{state.review && (
|
||||
<div className="file-conflict-list">
|
||||
{state.items.map((item) => (
|
||||
<div className="file-conflict-row" key={item.id}>
|
||||
<div>
|
||||
<strong>{item.label}</strong>
|
||||
<small>{item.kind === "folder" ? "Folder" : "File"} conflict at <code>{item.targetPath}</code></small>
|
||||
</div>
|
||||
<select value={item.action} onChange={(event) => onUpdateItem(item.id, { action: event.target.value as ConflictAction })} disabled={busy}>
|
||||
<option value="overwrite">Overwrite</option>
|
||||
<option value="rename">Rename</option>
|
||||
<option value="skip">Skip</option>
|
||||
</select>
|
||||
<input
|
||||
value={item.newPath}
|
||||
onChange={(event) => onUpdateItem(item.id, { newPath: event.target.value })}
|
||||
disabled={busy || item.action !== "rename"}
|
||||
aria-label={`New path for ${item.label}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!state.review && (
|
||||
<div className="placeholder-stack">
|
||||
{state.items.slice(0, 8).map((item) => <span key={item.id}><code>{item.targetPath}</code></span>)}
|
||||
{state.items.length > 8 && <span>… and {state.items.length - 8} more</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="button-row compact-actions align-end">
|
||||
<Button onClick={onCancel} disabled={busy}>Cancel</Button>
|
||||
<Button onClick={onOverwrite} disabled={busy}>Overwrite all</Button>
|
||||
<Button onClick={onRename} disabled={busy}>Rename all</Button>
|
||||
{!state.review && state.items.length > 1 && <Button onClick={onReview} disabled={busy}>Review individually</Button>}
|
||||
{state.review && <Button variant="primary" onClick={onApplyReview} disabled={busy}>Apply reviewed choices</Button>}
|
||||
</div>
|
||||
</FileDialog>
|
||||
);
|
||||
}
|
||||
6
webui/src/features/files/constants.ts
Normal file
6
webui/src/features/files/constants.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { FileFolder, FileSpace, ManagedFile } from "../../api/files";
|
||||
|
||||
export const EMPTY_SPACES: FileSpace[] = [];
|
||||
export const EMPTY_FILES: ManagedFile[] = [];
|
||||
export const EMPTY_FOLDERS: FileFolder[] = [];
|
||||
export const INTERNAL_DRAG_TYPE = "application/x-multi-seal-mail-file-selection";
|
||||
42
webui/src/features/files/hooks/useFileDialogs.ts
Normal file
42
webui/src/features/files/hooks/useFileDialogs.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useState } from "react";
|
||||
import type { ConflictDialogState, ContextMenuState, DeleteDialogState, DialogKind, FileActionTarget, TransferDialogState, TransferMode } from "../types";
|
||||
|
||||
export function useFileDialogs() {
|
||||
const [dialog, setDialog] = useState<DialogKind>(null);
|
||||
const [dialogTarget, setDialogTarget] = useState<FileActionTarget | null>(null);
|
||||
const [transferMode, setTransferMode] = useState<TransferMode>("move");
|
||||
const [transferDialogState, setTransferDialogState] = useState<TransferDialogState | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const [conflictDialog, setConflictDialog] = useState<ConflictDialogState | null>(null);
|
||||
const [deleteDialog, setDeleteDialog] = useState<DeleteDialogState | null>(null);
|
||||
|
||||
function openDialog(kind: DialogKind, target: FileActionTarget | null = null) {
|
||||
setDialogTarget(target);
|
||||
setDialog(kind);
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
setDialog(null);
|
||||
setDialogTarget(null);
|
||||
setTransferDialogState(null);
|
||||
}
|
||||
|
||||
return {
|
||||
dialog,
|
||||
setDialog,
|
||||
dialogTarget,
|
||||
setDialogTarget,
|
||||
transferMode,
|
||||
setTransferMode,
|
||||
transferDialogState,
|
||||
setTransferDialogState,
|
||||
contextMenu,
|
||||
setContextMenu,
|
||||
conflictDialog,
|
||||
setConflictDialog,
|
||||
deleteDialog,
|
||||
setDeleteDialog,
|
||||
openDialog,
|
||||
closeDialog
|
||||
};
|
||||
}
|
||||
23
webui/src/features/files/hooks/useFileDragDropState.ts
Normal file
23
webui/src/features/files/hooks/useFileDragDropState.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useState } from "react";
|
||||
import type { DragSelectionState } from "../types";
|
||||
|
||||
export function useFileDragDropState() {
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [internalDrag, setInternalDrag] = useState<DragSelectionState | null>(null);
|
||||
const [dropTargetKey, setDropTargetKey] = useState("");
|
||||
|
||||
function clearDropState() {
|
||||
setDropTargetKey("");
|
||||
setDragActive(false);
|
||||
}
|
||||
|
||||
return {
|
||||
dragActive,
|
||||
setDragActive,
|
||||
internalDrag,
|
||||
setInternalDrag,
|
||||
dropTargetKey,
|
||||
setDropTargetKey,
|
||||
clearDropState
|
||||
};
|
||||
}
|
||||
110
webui/src/features/files/hooks/useFileSelection.ts
Normal file
110
webui/src/features/files/hooks/useFileSelection.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useMemo, useState, type MouseEvent as ReactMouseEvent } from "react";
|
||||
import type { ExplorerEntry, EntrySelectionKey, SelectionSets } from "../types";
|
||||
import { entrySelectionKey } from "../utils/fileManagerUtils";
|
||||
|
||||
export function useFileSelection(visibleEntryKeys: EntrySelectionKey[], busy: boolean) {
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<Set<string>>(() => new Set());
|
||||
const [selectedFolderPaths, setSelectedFolderPaths] = useState<Set<string>>(() => new Set());
|
||||
const [selectionAnchor, setSelectionAnchor] = useState<EntrySelectionKey | null>(null);
|
||||
|
||||
const selectedEntryCount = selectedFileIds.size + selectedFolderPaths.size;
|
||||
const hasSelection = selectedEntryCount > 0;
|
||||
const selectedSummary = `${selectedFileIds.size} file(s), ${selectedFolderPaths.size} folder(s) selected`;
|
||||
|
||||
const currentSelectionKeySet = useMemo(() => {
|
||||
const keys = new Set<EntrySelectionKey>();
|
||||
selectedFileIds.forEach((id) => keys.add(`file:${id}`));
|
||||
selectedFolderPaths.forEach((path) => keys.add(`folder:${path}`));
|
||||
return keys;
|
||||
}, [selectedFileIds, selectedFolderPaths]);
|
||||
|
||||
function applySelectionKeys(keys: Set<EntrySelectionKey>) {
|
||||
const nextFileIds = new Set<string>();
|
||||
const nextFolderPaths = new Set<string>();
|
||||
keys.forEach((key) => {
|
||||
if (key.startsWith("file:")) nextFileIds.add(key.slice(5));
|
||||
if (key.startsWith("folder:")) nextFolderPaths.add(key.slice(7));
|
||||
});
|
||||
setSelectedFileIds(nextFileIds);
|
||||
setSelectedFolderPaths(nextFolderPaths);
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
setSelectedFileIds(new Set());
|
||||
setSelectedFolderPaths(new Set());
|
||||
setSelectionAnchor(null);
|
||||
}
|
||||
|
||||
function currentSelectionSets(): SelectionSets {
|
||||
return { fileIds: new Set(selectedFileIds), folderPaths: new Set(selectedFolderPaths) };
|
||||
}
|
||||
|
||||
function setsForEntry(entry: ExplorerEntry): SelectionSets {
|
||||
if (entry.kind === "folder") return { fileIds: new Set(), folderPaths: new Set([entry.path]) };
|
||||
return { fileIds: new Set([entry.id]), folderPaths: new Set() };
|
||||
}
|
||||
|
||||
function isEntrySelected(entry: ExplorerEntry): boolean {
|
||||
return entry.kind === "folder" ? selectedFolderPaths.has(entry.path) : selectedFileIds.has(entry.id);
|
||||
}
|
||||
|
||||
function preventTextSelectionOnShift(event: ReactMouseEvent<HTMLElement>) {
|
||||
if (event.shiftKey) event.preventDefault();
|
||||
}
|
||||
|
||||
function handleEntrySelection(entry: ExplorerEntry, event: ReactMouseEvent<HTMLElement>) {
|
||||
if (busy) return;
|
||||
const key = entrySelectionKey(entry);
|
||||
const currentKeys = new Set(currentSelectionKeySet);
|
||||
|
||||
if (event.shiftKey) event.preventDefault();
|
||||
|
||||
if (event.shiftKey && selectionAnchor) {
|
||||
const startIndex = visibleEntryKeys.indexOf(selectionAnchor);
|
||||
const endIndex = visibleEntryKeys.indexOf(key);
|
||||
if (startIndex >= 0 && endIndex >= 0) {
|
||||
const [from, to] = startIndex <= endIndex ? [startIndex, endIndex] : [endIndex, startIndex];
|
||||
const nextKeys = event.ctrlKey || event.metaKey ? currentKeys : new Set<EntrySelectionKey>();
|
||||
visibleEntryKeys.slice(from, to + 1).forEach((rangeKey) => nextKeys.add(rangeKey));
|
||||
applySelectionKeys(nextKeys);
|
||||
setSelectionAnchor(key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (currentKeys.has(key)) currentKeys.delete(key);
|
||||
else currentKeys.add(key);
|
||||
applySelectionKeys(currentKeys);
|
||||
setSelectionAnchor(key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentKeys.size === 1 && currentKeys.has(key)) {
|
||||
applySelectionKeys(new Set<EntrySelectionKey>());
|
||||
} else {
|
||||
applySelectionKeys(new Set<EntrySelectionKey>([key]));
|
||||
setSelectionAnchor(key);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
selectedFileIds,
|
||||
setSelectedFileIds,
|
||||
selectedFolderPaths,
|
||||
setSelectedFolderPaths,
|
||||
selectionAnchor,
|
||||
setSelectionAnchor,
|
||||
selectedEntryCount,
|
||||
hasSelection,
|
||||
selectedSummary,
|
||||
currentSelectionKeys: () => currentSelectionKeySet,
|
||||
applySelectionKeys,
|
||||
clearSelection,
|
||||
currentSelectionSets,
|
||||
setsForEntry,
|
||||
isEntrySelected,
|
||||
preventTextSelectionOnShift,
|
||||
handleEntrySelection
|
||||
};
|
||||
}
|
||||
87
webui/src/features/files/hooks/useFileTreeState.ts
Normal file
87
webui/src/features/files/hooks/useFileTreeState.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { folderAncestorPaths, isPathUnderOrSame, normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils";
|
||||
|
||||
export function useFileTreeState({
|
||||
activeSpaceId,
|
||||
currentFolder,
|
||||
onOpenFolder
|
||||
}: {
|
||||
activeSpaceId: string;
|
||||
currentFolder: string;
|
||||
onOpenFolder: (spaceId: string, path: string) => void;
|
||||
}) {
|
||||
const [expandedTreeNodes, setExpandedTreeNodes] = useState<Set<string>>(() => new Set());
|
||||
const dragExpandTimerRef = useRef<number | null>(null);
|
||||
const dragExpandKeyRef = useRef<string | null>(null);
|
||||
const suppressTreeAutoExpandKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSpaceId) return;
|
||||
const ancestors = folderAncestorPaths(currentFolder, { includeSelf: true });
|
||||
if (ancestors.length === 0) return;
|
||||
const suppressedKey = suppressTreeAutoExpandKeyRef.current;
|
||||
suppressTreeAutoExpandKeyRef.current = null;
|
||||
setExpandedTreeNodes((current) => {
|
||||
const next = new Set(current);
|
||||
ancestors.forEach((path) => {
|
||||
const key = treeNodeKey(activeSpaceId, path);
|
||||
if (key !== suppressedKey) next.add(key);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, [activeSpaceId, currentFolder]);
|
||||
|
||||
function cancelTreeDragExpand() {
|
||||
if (dragExpandTimerRef.current !== null) {
|
||||
window.clearTimeout(dragExpandTimerRef.current);
|
||||
dragExpandTimerRef.current = null;
|
||||
}
|
||||
dragExpandKeyRef.current = null;
|
||||
}
|
||||
|
||||
function expandTreeNode(spaceId: string, folderPath: string) {
|
||||
const key = treeNodeKey(spaceId, folderPath);
|
||||
setExpandedTreeNodes((current) => {
|
||||
if (current.has(key)) return current;
|
||||
const next = new Set(current);
|
||||
next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleTreeDragExpand(spaceId: string, folderPath: string) {
|
||||
const key = treeNodeKey(spaceId, folderPath);
|
||||
if (expandedTreeNodes.has(key) || dragExpandKeyRef.current === key) return;
|
||||
cancelTreeDragExpand();
|
||||
dragExpandKeyRef.current = key;
|
||||
dragExpandTimerRef.current = window.setTimeout(() => {
|
||||
expandTreeNode(spaceId, folderPath);
|
||||
dragExpandTimerRef.current = null;
|
||||
dragExpandKeyRef.current = null;
|
||||
}, 750);
|
||||
}
|
||||
|
||||
function toggleTreeFolder(spaceId: string, folderPath: string) {
|
||||
const normalizedPath = normalizeFolder(folderPath);
|
||||
const key = treeNodeKey(spaceId, normalizedPath);
|
||||
const isExpanded = expandedTreeNodes.has(key);
|
||||
if (isExpanded && spaceId === activeSpaceId && isPathUnderOrSame(currentFolder, normalizedPath)) {
|
||||
suppressTreeAutoExpandKeyRef.current = key;
|
||||
onOpenFolder(spaceId, normalizedPath);
|
||||
}
|
||||
setExpandedTreeNodes((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
expandedTreeNodes,
|
||||
setExpandedTreeNodes,
|
||||
cancelTreeDragExpand,
|
||||
scheduleTreeDragExpand,
|
||||
toggleTreeFolder
|
||||
};
|
||||
}
|
||||
69
webui/src/features/files/types.ts
Normal file
69
webui/src/features/files/types.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { ConflictAction, ManagedFile } from "../../api/files";
|
||||
|
||||
export type RenameMode = "prefix" | "suffix" | "replace";
|
||||
export type SortColumn = "name" | "size" | "modified";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
export type TransferMode = "move" | "copy";
|
||||
export type DialogKind = "upload" | "create-folder" | "rename" | "single-rename" | "transfer" | null;
|
||||
export type EntrySelectionKey = `file:${string}` | `folder:${string}`;
|
||||
export type ContextMenuState = {
|
||||
x: number;
|
||||
y: number;
|
||||
target: "empty" | "folder" | "file";
|
||||
entry?: ExplorerEntry;
|
||||
source?: "list" | "tree";
|
||||
spaceId?: string;
|
||||
folderPath?: string;
|
||||
};
|
||||
export type FileActionTarget = { spaceId: string; folderPath: string };
|
||||
export type SelectionSets = { fileIds: Set<string>; folderPaths: Set<string> };
|
||||
export type TransferDialogState = SelectionSets & { sourceSpaceId: string; targetSpaceId: string; targetFolder: string };
|
||||
export type DragSelectionState = { sourceSpaceId: string; fileIds: string[]; folderPaths: string[] };
|
||||
export type PendingUploadState = { files: File[]; target: FileActionTarget };
|
||||
export type PendingTransferState = { mode: TransferMode; sourceSpaceId: string; sets: SelectionSets; target: FileActionTarget };
|
||||
export type DeleteDialogState = SelectionSets & { spaceId: string; title: string; message: string };
|
||||
export type FileConflictItem = {
|
||||
id: string;
|
||||
kind: "file" | "folder";
|
||||
label: string;
|
||||
targetPath: string;
|
||||
action: ConflictAction;
|
||||
newPath: string;
|
||||
};
|
||||
export type ConflictDialogState = {
|
||||
operation: "upload" | "transfer";
|
||||
title: string;
|
||||
message: string;
|
||||
items: FileConflictItem[];
|
||||
pendingUpload?: PendingUploadState;
|
||||
pendingTransfer?: PendingTransferState;
|
||||
review: boolean;
|
||||
};
|
||||
|
||||
export type FolderNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
children: FolderNode[];
|
||||
fileCount: number;
|
||||
persisted: boolean;
|
||||
};
|
||||
|
||||
export type FolderEntry = {
|
||||
kind: "folder";
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
fileCount: number;
|
||||
folderCount: number;
|
||||
totalSize: number;
|
||||
updatedAt: string;
|
||||
persisted: boolean;
|
||||
};
|
||||
|
||||
export type FileEntry = {
|
||||
kind: "file";
|
||||
id: string;
|
||||
file: ManagedFile;
|
||||
};
|
||||
|
||||
export type ExplorerEntry = FolderEntry | FileEntry;
|
||||
303
webui/src/features/files/utils/fileManagerUtils.ts
Normal file
303
webui/src/features/files/utils/fileManagerUtils.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import type { FileFolder, ManagedFile } from "../../../api/files";
|
||||
import type { DragSelectionState, EntrySelectionKey, ExplorerEntry, FileEntry, FolderEntry, FolderNode, SortColumn, SortDirection } from "../types";
|
||||
|
||||
export function buildFolderTree(files: ManagedFile[], folders: FileFolder[]): FolderNode[] {
|
||||
const byPath = new Map<string, FolderNode>();
|
||||
const ensureNode = (path: string, persisted: boolean): FolderNode | null => {
|
||||
const normalized = normalizeFolder(path);
|
||||
if (!normalized) return null;
|
||||
const existing = byPath.get(normalized);
|
||||
if (existing) {
|
||||
existing.persisted = existing.persisted || persisted;
|
||||
return existing;
|
||||
}
|
||||
const parts = normalized.split("/");
|
||||
const node: FolderNode = {
|
||||
name: parts[parts.length - 1],
|
||||
path: normalized,
|
||||
children: [],
|
||||
fileCount: 0,
|
||||
persisted
|
||||
};
|
||||
byPath.set(normalized, node);
|
||||
const parentPath = parts.slice(0, -1).join("/");
|
||||
const parent = ensureNode(parentPath, false);
|
||||
if (parent) parent.children.push(node);
|
||||
return node;
|
||||
};
|
||||
for (const folder of folders) ensureNode(folder.path, true);
|
||||
for (const file of files) {
|
||||
const parts = normalizeFilePath(file.display_path).split("/").filter(Boolean);
|
||||
for (let index = 0; index < parts.length - 1; index += 1) {
|
||||
const node = ensureNode(parts.slice(0, index + 1).join("/"), false);
|
||||
if (node) node.fileCount += 1;
|
||||
}
|
||||
}
|
||||
const roots = Array.from(byPath.values()).filter((node) => !node.path.includes("/"));
|
||||
sortFolderNodes(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
export function sortFolderNodes(nodes: FolderNode[]) {
|
||||
nodes.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const node of nodes) sortFolderNodes(node.children);
|
||||
}
|
||||
|
||||
export function treeNodeKey(spaceId: string, folderPath: string): string {
|
||||
return `${spaceId}:${normalizeFolder(folderPath)}`;
|
||||
}
|
||||
|
||||
export function folderAncestorPaths(folderPath: string, options: { includeSelf?: boolean } = {}): string[] {
|
||||
const parts = normalizeFolder(folderPath).split("/").filter(Boolean);
|
||||
const limit = options.includeSelf ? parts.length : Math.max(parts.length - 1, 0);
|
||||
const result: string[] = [];
|
||||
for (let index = 1; index <= limit; index += 1) {
|
||||
result.push(parts.slice(0, index).join("/"));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isPathUnderOrSame(path: string, root: string): boolean {
|
||||
const normalizedPath = normalizeFolder(path);
|
||||
const normalizedRoot = normalizeFolder(root);
|
||||
if (!normalizedRoot) return true;
|
||||
return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`);
|
||||
}
|
||||
|
||||
export function buildExplorerEntries(files: ManagedFile[], folders: FileFolder[], currentFolder: string, searchActive: boolean): ExplorerEntry[] {
|
||||
if (searchActive) return files.map((file) => ({ kind: "file", id: file.id, file }));
|
||||
const folder = normalizeFolder(currentFolder);
|
||||
const prefix = folder ? `${folder}/` : "";
|
||||
const folderMap = new Map<string, FolderEntry>();
|
||||
const directFiles: FileEntry[] = [];
|
||||
|
||||
for (const persisted of folders) {
|
||||
const path = normalizeFolder(persisted.path);
|
||||
if (!path.startsWith(prefix) || path === folder) continue;
|
||||
const relative = prefix ? path.slice(prefix.length) : path;
|
||||
if (!relative || relative.includes("/")) continue;
|
||||
folderMap.set(path, {
|
||||
kind: "folder",
|
||||
id: `folder:${path}`,
|
||||
name: relative,
|
||||
path,
|
||||
fileCount: 0,
|
||||
folderCount: 0,
|
||||
totalSize: 0,
|
||||
updatedAt: persisted.updated_at,
|
||||
persisted: true
|
||||
});
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const path = normalizeFilePath(file.display_path || file.filename);
|
||||
if (prefix && !path.startsWith(prefix)) continue;
|
||||
const relativePath = prefix ? path.slice(prefix.length) : path;
|
||||
if (!relativePath) continue;
|
||||
const slashIndex = relativePath.indexOf("/");
|
||||
if (slashIndex === -1) {
|
||||
directFiles.push({ kind: "file", id: file.id, file });
|
||||
continue;
|
||||
}
|
||||
const folderName = relativePath.slice(0, slashIndex);
|
||||
const folderPath = prefix ? `${folder}/${folderName}` : folderName;
|
||||
const existing = folderMap.get(folderPath);
|
||||
if (existing) {
|
||||
existing.totalSize += file.size_bytes;
|
||||
if (file.updated_at > existing.updatedAt) existing.updatedAt = file.updated_at;
|
||||
} else {
|
||||
folderMap.set(folderPath, {
|
||||
kind: "folder",
|
||||
id: `folder:${folderPath}`,
|
||||
name: folderName,
|
||||
path: folderPath,
|
||||
fileCount: 0,
|
||||
folderCount: 0,
|
||||
totalSize: file.size_bytes,
|
||||
updatedAt: file.updated_at,
|
||||
persisted: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of folderMap.values()) {
|
||||
const counts = directFolderCounts(files, folders, entry.path);
|
||||
entry.fileCount = counts.files;
|
||||
entry.folderCount = counts.folders;
|
||||
}
|
||||
|
||||
return [...Array.from(folderMap.values()), ...directFiles];
|
||||
}
|
||||
|
||||
export function sortExplorerEntries(entries: ExplorerEntry[], column: SortColumn, direction: SortDirection): ExplorerEntry[] {
|
||||
const factor = direction === "asc" ? 1 : -1;
|
||||
return [...entries].sort((a, b) => {
|
||||
if (column === "name") {
|
||||
if (a.kind !== b.kind) return a.kind === "folder" ? (direction === "asc" ? -1 : 1) : (direction === "asc" ? 1 : -1);
|
||||
return factor * entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
if (column === "size") {
|
||||
const delta = entrySize(a) - entrySize(b);
|
||||
if (delta !== 0) return factor * delta;
|
||||
return entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
const timeA = entryModifiedTime(a);
|
||||
const timeB = entryModifiedTime(b);
|
||||
if (timeA !== timeB) return factor * (timeA - timeB);
|
||||
return entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" });
|
||||
});
|
||||
}
|
||||
|
||||
export function entryName(entry: ExplorerEntry): string {
|
||||
return entry.kind === "folder" ? entry.name : entry.file.filename;
|
||||
}
|
||||
|
||||
export function entrySize(entry: ExplorerEntry): number {
|
||||
return entry.kind === "folder" ? entry.totalSize : entry.file.size_bytes;
|
||||
}
|
||||
|
||||
export function entryModifiedTime(entry: ExplorerEntry): number {
|
||||
const raw = entry.kind === "folder" ? entry.updatedAt : entry.file.updated_at;
|
||||
const parsed = new Date(raw).getTime();
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
export function directFolderCounts(files: ManagedFile[], folders: FileFolder[], folderPath: string): { files: number; folders: number } {
|
||||
const normalized = normalizeFolder(folderPath);
|
||||
const prefix = normalized ? `${normalized}/` : "";
|
||||
const directFiles = files.filter((file) => parentFolderPath(file.display_path) === normalized).length;
|
||||
const childFolders = new Set<string>();
|
||||
for (const folder of folders) {
|
||||
const path = normalizeFolder(folder.path);
|
||||
if (!path.startsWith(prefix) || path === normalized) continue;
|
||||
const relative = prefix ? path.slice(prefix.length) : path;
|
||||
if (!relative) continue;
|
||||
childFolders.add(relative.split("/")[0]);
|
||||
}
|
||||
for (const file of files) {
|
||||
const path = normalizeFilePath(file.display_path);
|
||||
if (!path.startsWith(prefix)) continue;
|
||||
const relative = prefix ? path.slice(prefix.length) : path;
|
||||
if (!relative || !relative.includes("/")) continue;
|
||||
childFolders.add(relative.split("/")[0]);
|
||||
}
|
||||
return { files: directFiles, folders: childFolders.size };
|
||||
}
|
||||
|
||||
export function folderContentLabel(entry: FolderEntry): string {
|
||||
if (entry.fileCount === 0 && entry.folderCount === 0) return "empty";
|
||||
const parts: string[] = [];
|
||||
if (entry.fileCount > 0) parts.push(`${entry.fileCount} file(s)`);
|
||||
if (entry.folderCount > 0) parts.push(`${entry.folderCount} folder(s)`);
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
export function parentFolderPath(path: string): string {
|
||||
const parts = normalizeFilePath(path).split("/").filter(Boolean);
|
||||
return normalizeFolder(parts.slice(0, -1).join("/"));
|
||||
}
|
||||
|
||||
export function entrySelectionKey(entry: ExplorerEntry): EntrySelectionKey {
|
||||
return entry.kind === "folder" ? `folder:${entry.path}` : `file:${entry.id}`;
|
||||
}
|
||||
|
||||
export function buildFolderEntryFromSources(files: ManagedFile[], folders: FileFolder[], path: string): FolderEntry {
|
||||
const normalizedPath = normalizeFolder(path);
|
||||
const persistedFolder = folders.find((folder) => normalizeFolder(folder.path) === normalizedPath);
|
||||
const childFiles = files.filter((file) => isFileInFolder(file, normalizedPath));
|
||||
const sortedDates = childFiles.map((file) => file.updated_at).sort();
|
||||
const latestFileDate = sortedDates.length > 0 ? sortedDates[sortedDates.length - 1] : undefined;
|
||||
const updatedAt = latestFileDate || persistedFolder?.updated_at || new Date().toISOString();
|
||||
return {
|
||||
kind: "folder",
|
||||
id: `folder:${normalizedPath}`,
|
||||
name: lastPathSegment(normalizedPath) || "Root",
|
||||
path: normalizedPath,
|
||||
fileCount: directFolderCounts(files, folders, normalizedPath).files,
|
||||
folderCount: directFolderCounts(files, folders, normalizedPath).folders,
|
||||
totalSize: childFiles.reduce((total, file) => total + file.size_bytes, 0),
|
||||
updatedAt,
|
||||
persisted: Boolean(persistedFolder)
|
||||
};
|
||||
}
|
||||
|
||||
export function isFileInFolder(file: ManagedFile, folderPath: string): boolean {
|
||||
const normalizedFolder = normalizeFolder(folderPath);
|
||||
const filePath = normalizeFilePath(file.display_path);
|
||||
if (!normalizedFolder) return true;
|
||||
return filePath.startsWith(`${normalizedFolder}/`);
|
||||
}
|
||||
|
||||
export function fileIdsForSelection(files: ManagedFile[], selectedFileIds: Set<string>, selectedFolderPaths: Set<string>): string[] {
|
||||
const ids = new Set(selectedFileIds);
|
||||
for (const folderPath of selectedFolderPaths) {
|
||||
const normalizedFolder = normalizeFolder(folderPath);
|
||||
for (const file of files) {
|
||||
if (isFileInFolder(file, normalizedFolder)) ids.add(file.id);
|
||||
}
|
||||
}
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
export function folderBreadcrumbs(folder: string): { name: string; path: string }[] {
|
||||
const parts = normalizeFolder(folder).split("/").filter(Boolean);
|
||||
return parts.map((name, index) => ({ name, path: parts.slice(0, index + 1).join("/") }));
|
||||
}
|
||||
|
||||
export function joinFolder(folder: string, name: string): string {
|
||||
return normalizeFolder([folder, name].filter(Boolean).join("/"));
|
||||
}
|
||||
|
||||
export function normalizeFolder(value: string): string {
|
||||
return value.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").replace(/\/+/g, "/").trim();
|
||||
}
|
||||
|
||||
export function normalizeFilePath(value: string): string {
|
||||
return value.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+/g, "/").trim();
|
||||
}
|
||||
|
||||
export function lastPathSegment(path: string): string {
|
||||
const parts = normalizeFolder(path).split("/").filter(Boolean);
|
||||
return parts.length > 0 ? parts[parts.length - 1] : "";
|
||||
}
|
||||
|
||||
export function candidateRenamedPath(path: string, counter: number): string {
|
||||
const normalized = normalizeFilePath(path);
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
const name = parts.pop() ?? normalized;
|
||||
const lastDot = name.lastIndexOf(".");
|
||||
const hasExtension = lastDot > 0;
|
||||
const stem = hasExtension ? name.slice(0, lastDot) : name;
|
||||
const ext = hasExtension ? name.slice(lastDot) : "";
|
||||
const suffix = counter === 1 ? " copy" : ` copy ${counter}`;
|
||||
const nextName = `${stem}${suffix}${ext}`;
|
||||
return normalizeFilePath([...parts, nextName].join("/"));
|
||||
}
|
||||
|
||||
export function parseDragState(value: string): DragSelectionState | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<DragSelectionState>;
|
||||
if (!parsed.sourceSpaceId || !Array.isArray(parsed.fileIds) || !Array.isArray(parsed.folderPaths)) return null;
|
||||
return { sourceSpaceId: parsed.sourceSpaceId, fileIds: parsed.fileIds, folderPaths: parsed.folderPaths };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatBytes(value: number): string {
|
||||
if (value < 1024) return `${value} B`;
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let size = value / 1024;
|
||||
for (const unit of units) {
|
||||
if (size < 1024) return `${size.toFixed(size >= 10 ? 0 : 1)} ${unit}`;
|
||||
size /= 1024;
|
||||
}
|
||||
return `${size.toFixed(1)} PB`;
|
||||
}
|
||||
|
||||
export function formatDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString();
|
||||
}
|
||||
10
webui/src/index.ts
Normal file
10
webui/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import "./styles/file-manager.css";
|
||||
export { default } from "./module";
|
||||
export * from "./module";
|
||||
export { default as FilesPage } from "./features/files/FilesPage";
|
||||
export * from "./api/files";
|
||||
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
|
||||
export { FolderTree } from "./features/files/components/FileManagerComponents";
|
||||
export { useFileTreeState } from "./features/files/hooks/useFileTreeState";
|
||||
export type { FolderNode, SortColumn, SortDirection } from "./features/files/types";
|
||||
export { buildExplorerEntries, buildFolderEntryFromSources, buildFolderTree, candidateRenamedPath, directFolderCounts, entryModifiedTime, entryName, entrySelectionKey, entrySize, fileIdsForSelection, folderAncestorPaths, folderBreadcrumbs, folderContentLabel, formatBytes, formatDate, isFileInFolder, isPathUnderOrSame, joinFolder, lastPathSegment, normalizeFilePath, normalizeFolder, parentFolderPath, parseDragState, sortExplorerEntries, sortFolderNodes, treeNodeKey } from "./features/files/utils/fileManagerUtils";
|
||||
12
webui/src/module.ts
Normal file
12
webui/src/module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Folder } from "lucide-react";
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
|
||||
export const filesModule: PlatformWebModule = {
|
||||
id: "files",
|
||||
label: "Files",
|
||||
version: "1.0.0",
|
||||
dependencies: ["access"],
|
||||
navItems: [{ to: "/files", label: "Files", icon: Folder, anyOf: ["files:file:read"], order: 40 }]
|
||||
};
|
||||
|
||||
export default filesModule;
|
||||
721
webui/src/styles/file-manager.css
Normal file
721
webui/src/styles/file-manager.css
Normal file
@@ -0,0 +1,721 @@
|
||||
/* Files manager */
|
||||
.file-manager-page.file-manager-fullscreen {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
height: calc(100vh - 115px);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-manager-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--panel-header);
|
||||
}
|
||||
|
||||
.file-manager-toolbar .button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.file-manager-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.file-tree-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, var(--panel-soft), var(--panel));
|
||||
}
|
||||
|
||||
.file-tree-heading {
|
||||
flex: 0 0 auto;
|
||||
padding: 13px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.file-tree-list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 10px 8px 16px;
|
||||
}
|
||||
|
||||
.file-tree-space + .file-tree-space {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.file-tree-node-wrap {
|
||||
display: grid;
|
||||
grid-template-columns: 26px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.file-tree-node,
|
||||
.file-tree-select,
|
||||
.file-tree-toggle {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-tree-toggle {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 32px;
|
||||
border-radius: 9px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.file-tree-toggle:disabled {
|
||||
cursor: default;
|
||||
opacity: .55;
|
||||
}
|
||||
|
||||
.file-tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
padding: 8px 9px;
|
||||
border-radius: 9px;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.file-tree-node span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-tree-root {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.file-tree-select {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
color: var(--text-strong);
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.file-tree-node-wrap:hover,
|
||||
.file-tree-node-wrap:focus-within,
|
||||
.file-tree-node-wrap.is-active,
|
||||
.file-tree-root:hover:not(:disabled),
|
||||
.file-tree-root:focus-visible,
|
||||
.file-tree-root.is-active {
|
||||
background: rgba(13, 110, 253, .08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.file-tree-node:focus-visible,
|
||||
.file-tree-toggle:focus-visible,
|
||||
.file-tree-select:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.file-tree-node-wrap.is-drop-target,
|
||||
.file-tree-root.is-drop-target {
|
||||
background: rgba(13, 110, 253, .14);
|
||||
box-shadow: inset 0 0 0 2px rgba(13, 110, 253, .28);
|
||||
}
|
||||
|
||||
.file-tree-node-wrap.is-selected .file-tree-select {
|
||||
background: #0d6efd;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.file-tree-children {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.file-list-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.file-list-sticky {
|
||||
flex: 0 0 auto;
|
||||
z-index: 2;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.file-breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-height: 32px;
|
||||
padding: 10px 14px 6px;
|
||||
}
|
||||
|
||||
.file-breadcrumb,
|
||||
.file-breadcrumb-segment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.file-breadcrumb {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-strong);
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
padding: 4px 6px;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.file-breadcrumb:hover:not(:disabled),
|
||||
.file-breadcrumb:focus-visible {
|
||||
background: var(--panel);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.file-search-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(180px, 1fr) auto auto auto;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
padding: 4px 0 10px 14px;
|
||||
}
|
||||
|
||||
.file-list-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.file-list-drop-target {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
transition: background-color .16s ease, box-shadow .16s ease;
|
||||
}
|
||||
|
||||
.file-list-drop-target.is-active,
|
||||
.file-list-drop-target.is-drop-target {
|
||||
background: rgba(13, 110, 253, .05);
|
||||
box-shadow: inset 0 0 0 2px rgba(13, 110, 253, .22);
|
||||
}
|
||||
|
||||
.file-list-table {
|
||||
min-width: 760px;
|
||||
}
|
||||
|
||||
.file-list-table-head,
|
||||
.file-list-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 1fr) 120px 240px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.file-list-table-head {
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.file-list-table-head button {
|
||||
justify-self: start;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: inherit;
|
||||
letter-spacing: inherit;
|
||||
padding: 0;
|
||||
text-transform: inherit;
|
||||
}
|
||||
|
||||
.file-list-table-head button:hover,
|
||||
.file-list-table-head button:focus-visible {
|
||||
color: var(--text-strong);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.file-list-row {
|
||||
min-height: 58px;
|
||||
padding: 9px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.file-list-row:focus-visible {
|
||||
outline: 2px solid rgba(13, 110, 253, .35);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.file-list-row:hover,
|
||||
.file-list-row.is-selected {
|
||||
background: rgba(13, 110, 253, .07);
|
||||
}
|
||||
|
||||
.file-list-row.is-drop-target {
|
||||
background: rgba(13, 110, 253, .13);
|
||||
box-shadow: inset 0 0 0 2px rgba(13, 110, 253, .28);
|
||||
}
|
||||
|
||||
.file-parent-row.is-disabled {
|
||||
color: var(--muted);
|
||||
cursor: default;
|
||||
opacity: .58;
|
||||
}
|
||||
|
||||
.file-parent-row.is-disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.file-list-name-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-list-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: default;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.file-list-name > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.file-list-name strong,
|
||||
.file-list-name small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-list-name small {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.file-row-icon {
|
||||
color: var(--muted);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.folder-row .file-row-icon {
|
||||
color: #b6791d;
|
||||
}
|
||||
|
||||
.file-row-tail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.file-list-empty {
|
||||
padding: 36px 20px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-context-menu {
|
||||
position: fixed;
|
||||
z-index: 110;
|
||||
display: grid;
|
||||
min-width: 190px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line-dark);
|
||||
border-radius: 12px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow-popover);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.file-context-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
padding: 9px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.file-context-menu button:hover,
|
||||
.file-context-menu button:focus-visible {
|
||||
background: rgba(13, 110, 253, .08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.file-context-menu button.danger {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
|
||||
.file-dialog-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 22px;
|
||||
background: rgba(28, 25, 22, .38);
|
||||
}
|
||||
|
||||
.file-dialog {
|
||||
width: min(620px, 100%);
|
||||
max-height: min(720px, calc(100vh - 44px));
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line-dark);
|
||||
border-radius: 16px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow-popover);
|
||||
}
|
||||
|
||||
.file-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 15px 18px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.file-dialog-header h3,
|
||||
.file-dialog-header .file-dialog-title {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.file-dialog-header button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.file-dialog-body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.file-upload-drop-zone {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 8px;
|
||||
min-height: 170px;
|
||||
border: 1px dashed var(--line-dark);
|
||||
border-radius: 14px;
|
||||
background: var(--panel-soft);
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color .16s ease, background .16s ease, box-shadow .16s ease;
|
||||
}
|
||||
|
||||
.file-upload-drop-zone:hover,
|
||||
.file-upload-drop-zone:focus-visible,
|
||||
.file-upload-drop-zone.is-active {
|
||||
border-color: #0d6efd;
|
||||
background: rgba(13, 110, 253, .08);
|
||||
}
|
||||
|
||||
.file-upload-drop-zone:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(13, 110, 253, .18);
|
||||
}
|
||||
|
||||
.file-upload-drop-zone[aria-disabled="true"] {
|
||||
cursor: not-allowed;
|
||||
opacity: .65;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--danger-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.inline-link-button {
|
||||
justify-self: start;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.inline-link-button:hover,
|
||||
.inline-link-button:focus-visible {
|
||||
text-decoration: underline;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
|
||||
.file-search-row .toggle-switch-row {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-folder-selector {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
max-height: 290px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.file-folder-selector-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.file-folder-selector-node span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-folder-selector-node:hover:not(:disabled),
|
||||
.file-folder-selector-node:focus-visible,
|
||||
.file-folder-selector-node.is-selected {
|
||||
background: rgba(13, 110, 253, .09);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.file-folder-selector-node.is-selected {
|
||||
color: var(--text-strong);
|
||||
box-shadow: inset 0 0 0 1px rgba(13, 110, 253, .25);
|
||||
}
|
||||
|
||||
.file-folder-selector-empty {
|
||||
padding: 6px 10px 2px;
|
||||
}
|
||||
|
||||
.file-folder-selector-children {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.file-manager-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.file-tree-panel {
|
||||
max-height: 260px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.file-list-table-head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-list-table {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-list-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-search-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.file-manager-shell.is-loading .file-tree-panel,
|
||||
.file-manager-shell.is-loading .file-list-panel {
|
||||
filter: blur(1.5px);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.file-manager-loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 35;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(255, 255, 255, .12);
|
||||
backdrop-filter: blur(1px);
|
||||
}
|
||||
|
||||
.file-conflict-summary {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.file-conflict-summary span {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.file-conflict-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.file-conflict-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 140px minmax(180px, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.file-conflict-row > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-conflict-row small,
|
||||
.file-conflict-row code {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-conflict-row small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.file-conflict-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.rename-preview-panel {
|
||||
max-height: min(360px, 42vh);
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.rename-preview-more {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 1px dashed var(--line-dark);
|
||||
border-radius: 6px;
|
||||
background: var(--panel-soft);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.rename-preview-more:hover,
|
||||
.rename-preview-more:focus-visible {
|
||||
border-color: rgba(13, 110, 253, .45);
|
||||
background: rgba(13, 110, 253, .08);
|
||||
color: var(--text-strong);
|
||||
outline: none;
|
||||
}
|
||||
Reference in New Issue
Block a user