initial commit after split

This commit is contained in:
2026-06-24 01:43:16 +02:00
parent bbd6ff4f61
commit 0db662380b
42 changed files with 7028 additions and 0 deletions

26
webui/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "@govoplan/files-webui",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/file-manager.css": "./src/styles/file-manager.css"
},
"peerDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.6",
"typescript": "^5.7.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"lucide-react": "^0.555.0",
"@govoplan/core-webui": "^0.1.0"
}
}

201
webui/src/api/files.ts Normal file
View 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);
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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";

View 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
};
}

View 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
};
}

View 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
};
}

View 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
};
}

View 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;

View 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
View 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
View 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;

View 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;
}