chore: sync GovOPlaN module split state

This commit is contained in:
2026-07-10 12:51:21 +02:00
parent 18e6c3eb9b
commit 13fd7fc3bd
50 changed files with 12678 additions and 1109 deletions

View File

@@ -1,6 +1,6 @@
import type { CSSProperties, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
import { Copy, Download, FolderOpen, Home, MoveRight, Plus, Trash2, UploadCloud } from "lucide-react";
import { Button, Dialog, ExplorerTree, type ExplorerTreeNodeContext } from "@govoplan/core-webui";
import { Button, Dialog, ExplorerTree, type ExplorerTreeNodeContext, i18nMessage } 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";
@@ -11,28 +11,28 @@ export function TransferFolderSelector({
selectedFolder,
onSelect,
disabled
}: {
space: FileSpace | null;
nodes: FolderNode[];
selectedFolder: string;
onSelect: (folderPath: string) => void;
disabled?: boolean;
}) {
}: {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">
<div className="file-folder-selector" role="tree" aria-label="i18n:govoplan-files.destination_folder.f8ccb63b">
<button
type="button"
className={`file-folder-selector-node ${selectedFolder === "" ? "is-selected" : ""}`}
onClick={() => onSelect("")}
disabled={disabled || !space}
>
disabled={disabled || !space}>
<Home size={15} aria-hidden="true" />
<span>{space?.label || "Root"}</span>
<span>{space?.label || "i18n:govoplan-files.root.e96857c5"}</span>
</button>
{nodes.length === 0 && <span className="muted small-text file-folder-selector-empty">No folders yet. Choose the root folder.</span>}
{nodes.length === 0 && <span className="muted small-text file-folder-selector-empty">i18n:govoplan-files.no_folders_yet_choose_the_root_folder.6430304d</span>}
<TransferFolderSelectorNodes nodes={nodes} selectedFolder={selectedFolder} onSelect={onSelect} disabled={disabled} />
</div>
);
</div>);
}
export function TransferFolderSelectorNodes({
@@ -41,33 +41,33 @@ export function TransferFolderSelectorNodes({
onSelect,
disabled,
depth = 1
}: {
nodes: FolderNode[];
selectedFolder: string;
onSelect: (folderPath: string) => void;
disabled?: boolean;
depth?: number;
}) {
}: {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}>
{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}
>
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>
);
)}
</div>);
}
export function FolderTree({
@@ -90,27 +90,27 @@ export function FolderTree({
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;
}) {
}: {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;}) {
return (
<ExplorerTree
nodes={nodes}
@@ -138,9 +138,9 @@ export function FolderTree({
if (context.hasChildren && !context.expanded) onRequestDragExpand?.(spaceId, node.path);
} : undefined}
onDragLeave={dragDropEnabled && onClearDropState ? () => onClearDropState() : undefined}
onDrop={dragDropEnabled && onDropOnTarget ? (event, node) => void onDropOnTarget(event, { spaceId, folderPath: node.path }) : undefined}
/>
);
onDrop={dragDropEnabled && onDropOnTarget ? (event, node) => void onDropOnTarget(event, { spaceId, folderPath: node.path }) : undefined} />);
}
export function RenamePreviewList({
@@ -151,23 +151,23 @@ export function RenamePreviewList({
visibleCount,
onShowMore,
onShowFewer
}: {
response: RenameResponse;
selectedFileIds: Set<string>;
selectedFolderPaths: Set<string>;
recursive: boolean;
visibleCount: number;
onShowMore: () => void;
onShowFewer: () => void;
}) {
}: {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 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") {
@@ -182,20 +182,20 @@ export function RenamePreviewList({
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>}
{hiddenNonRecursiveItems > 0 && <span className="muted">{hiddenNonRecursiveItems} i18n:govoplan-files.contained_path_s_will_move_with_the_selected_fol.b786f1a1</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>
)}
{remaining > 0 &&
<button type="button" className="rename-preview-more" onClick={onShowMore}>i18n:govoplan-files.and.a0d93385 {remaining} i18n:govoplan-files.more_show_next.f1912318 {Math.min(20, remaining)}</button>
}
{shownItems.length > 20 &&
<button type="button" className="rename-preview-more" onClick={onShowFewer}>i18n:govoplan-files.show_fewer.d94dfbe5</button>
}
</div>
</div>
);
</div>);
}
export function FileDialog({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) {
export function FileDialog({ title, onClose, children }: {title: string;onClose: () => void;children: ReactNode;}) {
return (
<Dialog
open
@@ -205,11 +205,11 @@ export function FileDialog({ title, onClose, children }: { title: string; onClos
className="file-dialog"
headerClassName="file-dialog-header"
titleClassName="file-dialog-title"
bodyClassName="file-dialog-body"
>
bodyClassName="file-dialog-body">
{children}
</Dialog>
);
</Dialog>);
}
export function FileContextMenu({
@@ -227,22 +227,22 @@ export function FileContextMenu({
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;
}) {
}: {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;
@@ -251,19 +251,19 @@ export function FileContextMenu({
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) };
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>
{showNewFolder && <button type="button" role="menuitem" onClick={onCreateFolder} disabled={!canCreateFolder}><Plus size={15} aria-hidden="true" /> i18n:govoplan-files.new_folder.a711999b</button>}
<button type="button" role="menuitem" onClick={onUpload} disabled={!canUpload}><UploadCloud size={15} aria-hidden="true" /> i18n:govoplan-files.upload.8bdf057f</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>
);
<button type="button" role="menuitem" onClick={onMove} disabled={!hasSelection || !canOrganize}><MoveRight size={15} aria-hidden="true" /> i18n:govoplan-files.move.8a74a26e</button>
<button type="button" role="menuitem" onClick={onCopy} disabled={!hasSelection || !canOrganize}><Copy size={15} aria-hidden="true" /> i18n:govoplan-files.copy.92556c6d</button>
{showDelete && <button type="button" role="menuitem" className="danger" onClick={onDelete} disabled={!canDelete}><Trash2 size={15} aria-hidden="true" /> i18n:govoplan-files.delete.f6fdbe48</button>}
</div>);
}
export function FileConflictDialog({
@@ -276,60 +276,60 @@ export function FileConflictDialog({
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;
}) {
}: {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>
<strong>{state.items.length} i18n:govoplan-files.conflict_s.60cea6d5</strong>
<span>{state.items.length > 1 ? "i18n:govoplan-files.choose_the_same_action_for_all_conflicts_or_revi.319cfe5f" : "i18n:govoplan-files.choose_how_to_resolve_this_conflict.c303fcc6"}</span>
</div>
{state.review && (
<div className="file-conflict-list">
{state.items.map((item) => (
<div className="file-conflict-row" key={item.id}>
{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>
<small>{item.kind === "folder" ? "i18n:govoplan-files.folder.30baa249" : "i18n:govoplan-files.file.2c3cafa4"} i18n:govoplan-files.conflict_at.0a91052f <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>
<option value="overwrite">i18n:govoplan-files.overwrite.0625c54e</option>
<option value="rename">i18n:govoplan-files.rename.d3f4cb89</option>
<option value="skip">i18n:govoplan-files.skip.3da47453</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}`}
/>
value={item.newPath}
onChange={(event) => onUpdateItem(item.id, { newPath: event.target.value })}
disabled={busy || item.action !== "rename"}
aria-label={i18nMessage("i18n:govoplan-files.new_path_for_value.a9a30c1c", { value0: item.label })} />
</div>
))}
)}
</div>
)}
{!state.review && (
<div className="placeholder-stack">
}
{!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>}
{state.items.length > 8 && <span>i18n:govoplan-files.and.a0d93385 {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>}
<Button onClick={onCancel} disabled={busy}>i18n:govoplan-files.cancel.77dfd213</Button>
<Button onClick={onOverwrite} disabled={busy}>i18n:govoplan-files.overwrite_all.a76f2d04</Button>
<Button onClick={onRename} disabled={busy}>i18n:govoplan-files.rename_all.1d6b24dc</Button>
{!state.review && state.items.length > 1 && <Button onClick={onReview} disabled={busy}>i18n:govoplan-files.review_individually.19abbe58</Button>}
{state.review && <Button variant="primary" onClick={onApplyReview} disabled={busy}>i18n:govoplan-files.apply_reviewed_choices.bb55f7b3</Button>}
</div>
</FileDialog>
);
</FileDialog>);
}