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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

View File

@@ -3,24 +3,24 @@ import { createPortal } from "react-dom";
import { ArrowDown, ArrowUp, ArrowUpDown, File, Folder, FolderOpen, Home, Link2, Search } from "lucide-react";
import {
listFileSpaces,
listFiles,
listFolders,
listManagedFileSnapshot,
resolveFilePatterns,
shareFilesWithTarget,
type FileFolder,
type FileSpace,
type ManagedFile
} from "../../../api/files";
type ManagedFile } from
"../../../api/files";
import {
Button,
ConfirmDialog,
Dialog,
DismissibleAlert,
LoadingFrame,
type FilesManagedAttachmentSelection,
type FilesManagedFileChooserProps,
type FilesManagedFileLinkTarget,
type FilesManagedFolderSelection
} from "@govoplan/core-webui";
type FilesManagedFolderSelection, i18nMessage } from
"@govoplan/core-webui";
import { useFileTreeState } from "../hooks/useFileTreeState";
import type { FolderNode, SortColumn, SortDirection } from "../types";
import {
@@ -32,8 +32,8 @@ import {
normalizeFolder,
parentFolderPath,
sortExplorerEntries,
treeNodeKey
} from "../utils/fileManagerUtils";
treeNodeKey } from
"../utils/fileManagerUtils";
type ManagedFileChooserMode = FilesManagedFileChooserProps["mode"];
@@ -57,7 +57,7 @@ type ManagedFileSource = {
const MANAGED_FILE_SOURCE_PREFIX = "managed:";
type ChooserExplorerEntry = ReturnType<typeof buildExplorerEntries>[number];
type VirtualFolderTreeRow = { node: FolderNode; depth: number };
type VirtualFolderTreeRow = {node: FolderNode;depth: number;};
export type ManagedFolderSelection = FilesManagedFolderSelection;
export type ManagedAttachmentSelection = FilesManagedAttachmentSelection;
@@ -104,9 +104,9 @@ export default function ManagedFileChooser({
const selectedSpace = spaces.find((item) => item.id === selectedSpaceId) ?? null;
const sourceLocked = mode === "attachment" && Boolean(parsedSource);
const sourceMatchesSpace = selectedSpace
? mode === "folder" || !parsedSource || (selectedSpace.owner_type === parsedSource.ownerType && selectedSpace.owner_id === parsedSource.ownerId)
: false;
const sourceMatchesSpace = selectedSpace ?
mode === "folder" || !parsedSource || selectedSpace.owner_type === parsedSource.ownerType && selectedSpace.owner_id === parsedSource.ownerId :
false;
const effectiveRoot = mode === "attachment" ? normalizedBasePath : "";
const entries = useMemo(
() => buildExplorerEntries(files, folders, currentFolder, false),
@@ -187,22 +187,23 @@ export default function ManagedFileChooser({
setExactSelectionPattern("");
setPatternMatches(null);
setPendingExactFile(null);
void listFileSpaces(settings)
.then((response) => {
if (cancelled) return;
setSpaces(response.spaces);
const sourceSpace = response.spaces.find((item) => sourceMatches(item, parsedSource));
const rememberedSpace = response.spaces.find((item) => item.id === remembered.spaceId);
const firstSpace = sourceSpace ?? rememberedSpace ?? response.spaces[0] ?? null;
setSelectedSpaceId(firstSpace?.id ?? "");
})
.catch((reason: unknown) => {
if (!cancelled) setError(errorMessage(reason));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
void listFileSpaces(settings).
then((response) => {
if (cancelled) return;
const managedSpaces = response.spaces.filter((space) => space.space_type !== "connector");
setSpaces(managedSpaces);
const sourceSpace = managedSpaces.find((item) => sourceMatches(item, parsedSource));
const rememberedSpace = managedSpaces.find((item) => item.id === remembered.spaceId);
const firstSpace = sourceSpace ?? rememberedSpace ?? managedSpaces[0] ?? null;
setSelectedSpaceId(firstSpace?.id ?? "");
}).
catch((reason: unknown) => {
if (!cancelled) setError(errorMessage(reason));
}).
finally(() => {
if (!cancelled) setLoading(false);
});
return () => {cancelled = true;};
}, [initialPattern, open, parsedSource?.ownerId, parsedSource?.ownerType, settings.apiBaseUrl, settings.apiKey, settings.accessToken, storageKey]);
useEffect(() => {
@@ -214,22 +215,23 @@ export default function ManagedFileChooser({
const rememberedFolder = selectedSpace.id === remembered.spaceId ? normalizeFolder(remembered.folder || "") : "";
const initialFolder = rememberedFolder && isWithinRoot(rememberedFolder, effectiveRoot) ? rememberedFolder : effectiveRoot;
setCurrentFolder(initialFolder || effectiveRoot);
void Promise.all([
listFiles(settings, { owner_type: selectedSpace.owner_type, owner_id: selectedSpace.owner_id, path_prefix: mode === "attachment" ? effectiveRoot || undefined : undefined }),
listFolders(settings, { owner_type: selectedSpace.owner_type, owner_id: selectedSpace.owner_id })
])
.then(([fileResponse, folderResponse]) => {
if (cancelled) return;
setFiles(fileResponse.files);
setFolders(folderResponse.folders);
})
.catch((reason: unknown) => {
if (!cancelled) setError(errorMessage(reason));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
void listManagedFileSnapshot(settings, {
owner_type: selectedSpace.owner_type,
owner_id: selectedSpace.owner_id,
path_prefix: mode === "attachment" ? effectiveRoot || undefined : undefined
}).
then((response) => {
if (cancelled) return;
setFiles(response.files);
setFolders(response.folders);
}).
catch((reason: unknown) => {
if (!cancelled) setError(errorMessage(reason));
}).
finally(() => {
if (!cancelled) setLoading(false);
});
return () => {cancelled = true;};
}, [effectiveRoot, mode, open, selectedSpace?.id, settings.apiBaseUrl, settings.apiKey, settings.accessToken, storageKey]);
useEffect(() => {
@@ -287,9 +289,9 @@ export default function ManagedFileChooser({
async function shareMatches(matches: ManagedFile[]) {
if (!linkTarget) return;
const fileIds = matches
.filter((file) => !isLinkedToTarget(file, linkTarget))
.map((file) => file.id);
const fileIds = matches.
filter((file) => !isLinkedToTarget(file, linkTarget)).
map((file) => file.id);
const uniqueIds = [...new Set(fileIds)];
if (uniqueIds.length > 0) await shareFilesWithTarget(settings, uniqueIds, linkTarget);
}
@@ -308,7 +310,7 @@ export default function ManagedFileChooser({
return;
}
const matches = patternMatches ?? await previewPattern();
const matches = patternMatches ?? (await previewPattern());
await shareMatches(matches);
const trimmedPattern = patternRef.current.trim();
onSelectAttachment?.({
@@ -329,148 +331,150 @@ export default function ManagedFileChooser({
if (!open || typeof document === "undefined") return null;
const dialog = (
<>
const dialog =
<>
<Dialog
open
title={mode === "folder" ? "Choose managed attachment source" : "Choose managed file or pattern"}
onClose={onClose}
closeDisabled={submitting}
backdropClassName="managed-file-chooser-backdrop"
className="managed-file-chooser-dialog"
bodyClassName="managed-file-chooser-body"
footerClassName="managed-file-chooser-footer"
footer={(
<>
<Button onClick={onClose} disabled={submitting}>Cancel</Button>
open
title={mode === "folder" ? "i18n:govoplan-files.choose_managed_attachment_source.b3709493" : "i18n:govoplan-files.choose_managed_file_or_pattern.bcf8e183"}
onClose={onClose}
closeDisabled={submitting}
backdropClassName="managed-file-chooser-backdrop"
className="managed-file-chooser-dialog"
bodyClassName="managed-file-chooser-body"
footerClassName="managed-file-chooser-footer"
footer={
<>
<Button onClick={onClose} disabled={submitting}>i18n:govoplan-files.cancel.77dfd213</Button>
<Button
variant="primary"
onClick={() => void confirmSelection()}
disabled={loading || submitting || !selectedSpace || !sourceMatchesSpace || (mode === "attachment" && (!parsedSource || !patternHasText))}
>
{submitting ? (linkTarget ? "Linking…" : "Saving…") : mode === "folder" ? "Use this folder" : "Use pattern"}
variant="primary"
onClick={() => void confirmSelection()}
disabled={loading || submitting || !selectedSpace || !sourceMatchesSpace || mode === "attachment" && (!parsedSource || !patternHasText)}>
{submitting ? linkTarget ? "i18n:govoplan-files.linking.6f640897" : "i18n:govoplan-files.saving.56a2285c" : mode === "folder" ? "i18n:govoplan-files.use_this_folder.0e77d6d1" : "i18n:govoplan-files.use_pattern.ce54ba3e"}
</Button>
</>
)}
>
}>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{mode === "attachment" && !parsedSource && (
<DismissibleAlert tone="warning" dismissible={false}>
This attachment base path is not connected to a managed file space. Choose its folder under <strong>Attachments Attachment sources</strong> first.
{mode === "attachment" && !parsedSource &&
<DismissibleAlert tone="warning" dismissible={false}>
i18n:govoplan-files.this_attachment_base_path_is_not_connected_to_a_.3962b48f <strong>i18n:govoplan-files.attachments_attachment_sources.87d92d5b</strong> i18n:govoplan-files.first.49ec0838
</DismissibleAlert>
)}
{mode === "attachment" && parsedSource && !sourceMatchesSpace && !loading && (
<DismissibleAlert tone="danger" dismissible={false}>The configured managed file space is no longer available to this user.</DismissibleAlert>
)}
}
{mode === "attachment" && parsedSource && !sourceMatchesSpace && !loading &&
<DismissibleAlert tone="danger" dismissible={false}>i18n:govoplan-files.the_configured_managed_file_space_is_no_longer_a.5e93b729</DismissibleAlert>
}
<LoadingFrame loading={loading} label="i18n:govoplan-files.loading_connector_files.e5b0871d" className="managed-file-chooser-loading-frame">
<div className="managed-file-chooser-layout" aria-busy={loading}>
<aside className="managed-file-chooser-spaces file-tree-panel" aria-label="File spaces and folders">
<div className="file-tree-heading">Spaces</div>
<aside className="managed-file-chooser-spaces file-tree-panel" aria-label="i18n:govoplan-files.file_spaces_and_folders.443d2056">
<div className="file-tree-heading">i18n:govoplan-files.spaces.9b584b52</div>
<div className="file-tree-list">
{spaces.map((space) => {
const selected = space.id === selectedSpaceId;
const lockedOut = sourceLocked && !sourceMatches(space, parsedSource);
return (
<div className={`file-tree-space ${selected ? "is-active" : ""}`} key={space.id}>
const selected = space.id === selectedSpaceId;
const lockedOut = sourceLocked && !sourceMatches(space, parsedSource);
return (
<div className={`file-tree-space ${selected ? "is-active" : ""}`} key={space.id}>
<button
type="button"
className={`file-tree-node file-tree-root ${selected && currentFolder === effectiveRoot ? "is-active" : ""}`}
onClick={() => {
if (!selected) setSelectedSpaceId(space.id);
else openFolder(effectiveRoot);
}}
disabled={loading || lockedOut}
>
type="button"
className={`file-tree-node file-tree-root ${selected && currentFolder === effectiveRoot ? "is-active" : ""}`}
onClick={() => {
if (!selected) setSelectedSpaceId(space.id);else
openFolder(effectiveRoot);
}}
disabled={loading || lockedOut}>
{space.owner_type === "group" ? <FolderOpen size={15} aria-hidden="true" /> : <Home size={15} aria-hidden="true" />}
<span>{space.label}</span>
</button>
{selected && (
<VirtualChooserFolderTree
nodes={treeNodes}
activeSpaceId={selectedSpaceId}
spaceId={space.id}
currentFolder={currentFolder}
expandedKeys={expandedTreeNodes}
onOpen={(_spaceId, path) => openFolder(path)}
onToggle={toggleTreeFolder}
disabled={loading}
/>
)}
</div>
);
})}
{!loading && spaces.length === 0 && <p className="muted small-note">No accessible file spaces were found.</p>}
{selected &&
<VirtualChooserFolderTree
nodes={treeNodes}
activeSpaceId={selectedSpaceId}
spaceId={space.id}
currentFolder={currentFolder}
expandedKeys={expandedTreeNodes}
onOpen={(_spaceId, path) => openFolder(path)}
onToggle={toggleTreeFolder}
disabled={loading} />
}
</div>);
})}
{!loading && spaces.length === 0 && <p className="muted small-note">i18n:govoplan-files.no_accessible_file_spaces_were_found.abcf5003</p>}
</div>
</aside>
<section className={`managed-file-chooser-browser ${mode === "folder" ? "folder-mode" : "attachment-mode"}`}>
<div className="managed-file-chooser-toolbar">
<div className="managed-file-breadcrumb" aria-label="Current folder">
<div className="managed-file-breadcrumb" aria-label="i18n:govoplan-files.current_folder.5aeab2f0">
<button type="button" onClick={() => openFolder(effectiveRoot)} disabled={loading}>
<Home size={14} aria-hidden="true" /> {selectedSpace?.label || "Files"}
<Home size={14} aria-hidden="true" /> {selectedSpace?.label || "i18n:govoplan-files.files.6ce6c512"}
</button>
{breadcrumbs.map((crumb) => (
<span key={crumb.path}>
{breadcrumbs.map((crumb) =>
<span key={crumb.path}>
<span aria-hidden="true">/</span>
<button type="button" onClick={() => openFolder(crumb.path)} disabled={loading}>{crumb.name}</button>
</span>
))}
)}
</div>
</div>
{mode === "attachment" && (
<ManagedPatternEditor
value={pattern}
effectiveRoot={effectiveRoot}
resolving={resolving}
matchCount={patternMatches?.length ?? null}
previewContext={previewContext}
renderPatternPreview={renderPatternPreview}
onDraftChange={updatePatternInput}
onPreview={() => void previewPattern()}
onBackToFolder={() => setPatternMatches(null)}
/>
)}
{mode === "attachment" &&
<ManagedPatternEditor
value={pattern}
effectiveRoot={effectiveRoot}
resolving={resolving}
matchCount={patternMatches?.length ?? null}
previewContext={previewContext}
renderPatternPreview={renderPatternPreview}
onDraftChange={updatePatternInput}
onPreview={() => void previewPattern()}
onBackToFolder={() => setPatternMatches(null)} />
{patternMatches !== null && mode === "attachment" ? (
<PatternResultList files={patternMatches} linkTarget={linkTarget} onChoose={requestExactFile} />
) : (
<ChooserFileBrowser
mode={mode}
loading={loading}
currentFolder={currentFolder}
effectiveRoot={effectiveRoot}
sortedEntries={sortedEntries}
sortColumn={sortColumn}
sortDirection={sortDirection}
linkTarget={linkTarget}
selectedExactPattern={exactSelectionPattern}
onOpenFolder={openFolder}
onToggleSort={toggleSort}
onRequestExactFile={requestExactFile}
/>
)}
}
{mode === "folder" && (
<p className="muted small-note managed-file-chooser-note">Choose the folder that should act as this campaign attachment source.</p>
)}
{mode === "attachment" && (
<p className="muted small-note managed-file-chooser-note">Clicking a file sets its exact relative path as the pattern. Preview resolves the pattern against the current managed file space.</p>
)}
{patternMatches !== null && mode === "attachment" ?
<PatternResultList files={patternMatches} linkTarget={linkTarget} onChoose={requestExactFile} /> :
<ChooserFileBrowser
mode={mode}
loading={loading}
currentFolder={currentFolder}
effectiveRoot={effectiveRoot}
sortedEntries={sortedEntries}
sortColumn={sortColumn}
sortDirection={sortDirection}
linkTarget={linkTarget}
selectedExactPattern={exactSelectionPattern}
onOpenFolder={openFolder}
onToggleSort={toggleSort}
onRequestExactFile={requestExactFile} />
}
{mode === "folder" &&
<p className="muted small-note managed-file-chooser-note">i18n:govoplan-files.choose_the_folder_that_should_act_as_this_campai.9d0b7ef7</p>
}
{mode === "attachment" &&
<p className="muted small-note managed-file-chooser-note">i18n:govoplan-files.clicking_a_file_sets_its_exact_relative_path_as_.590abd24</p>
}
</section>
</div>
</LoadingFrame>
</Dialog>
<ConfirmDialog
open={Boolean(pendingExactFile)}
title="Replace the current pattern?"
message={pendingExactFile ? `Replace "${patternRef.current.trim()}" with the exact file "${relativePath(pendingExactFile.display_path, effectiveRoot)}"?` : "Replace the current pattern?"}
confirmLabel="Replace pattern"
onConfirm={() => pendingExactFile && applyExactFile(pendingExactFile)}
onCancel={() => setPendingExactFile(null)}
/>
</>
);
open={Boolean(pendingExactFile)}
title="i18n:govoplan-files.replace_the_current_pattern.96f80707"
message={pendingExactFile ? i18nMessage("i18n:govoplan-files.replace_value_with_the_exact_file_value.4a63236f", { value0: patternRef.current.trim(), value1: relativePath(pendingExactFile.display_path, effectiveRoot) }) : "i18n:govoplan-files.replace_the_current_pattern.96f80707"}
confirmLabel="i18n:govoplan-files.replace_pattern.d98e4c92"
onConfirm={() => pendingExactFile && applyExactFile(pendingExactFile)}
onCancel={() => setPendingExactFile(null)} />
</>;
return createPortal(dialog, document.body);
}
@@ -480,26 +484,26 @@ function ChooserSortButton({
label,
activeColumn,
direction,
onSort,
}: {
column: SortColumn;
label: string;
activeColumn: SortColumn;
direction: SortDirection;
onSort: (column: SortColumn) => void;
}) {
onSort
}: {column: SortColumn;label: string;activeColumn: SortColumn;direction: SortDirection;onSort: (column: SortColumn) => void;}) {
const active = activeColumn === column;
const Icon = active ? (direction === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown;
const Icon = active ? direction === "asc" ? ArrowUp : ArrowDown : ArrowUpDown;
return (
<button
type="button"
className={active ? "is-sorted" : ""}
aria-label={`${label}: ${active ? `sorted ${direction === "asc" ? "ascending" : "descending"}` : "not sorted"}. Activate to sort.`}
onClick={() => onSort(column)}
>
aria-label={i18nMessage("i18n:govoplan-files.value_value_activate_to_sort.3953ba71", { value0: label, value1: active ? `sorted ${direction === "asc" ? "ascending" : "descending"}` : "i18n:govoplan-files.not_sorted.0abc03c5" })}
onClick={() => onSort(column)}>
<span>{label}</span><Icon size={14} aria-hidden="true" />
</button>
);
</button>);
}
function ManagedPatternEditor({
@@ -512,17 +516,17 @@ function ManagedPatternEditor({
onDraftChange,
onPreview,
onBackToFolder
}: {
value: string;
effectiveRoot: string;
resolving: boolean;
matchCount: number | null;
previewContext?: Record<string, string>;
renderPatternPreview?: FilesManagedFileChooserProps["renderPatternPreview"];
onDraftChange: (value: string) => void;
onPreview: () => void;
onBackToFolder: () => void;
}) {
}: {value: string;effectiveRoot: string;resolving: boolean;matchCount: number | null;previewContext?: Record<string, string>;renderPatternPreview?: FilesManagedFileChooserProps["renderPatternPreview"];onDraftChange: (value: string) => void;onPreview: () => void;onBackToFolder: () => void;}) {
const [draft, setDraft] = useState(value);
const renderedPattern = useMemo(() => renderChooserPattern(draft, previewContext, renderPatternPreview), [draft, previewContext, renderPatternPreview]);
const patternIsRendered = Boolean(draft.trim() && renderedPattern && renderedPattern !== draft.trim());
@@ -539,28 +543,28 @@ function ManagedPatternEditor({
return (
<div className="managed-pattern-editor">
<label>
<span>File or pattern relative to <code>{effectiveRoot || "."}</code></span>
<span>i18n:govoplan-files.file_or_pattern_relative_to.0ab4d831 <code>{effectiveRoot || "."}</code></span>
<div className="field-with-action split-field-action">
<input
value={draft}
onChange={(event) => updateDraft(event.target.value)}
onKeyDown={(event) => { if (event.key === "Enter") onPreview(); }}
placeholder="folder/file.pdf or **/*.pdf"
/>
onKeyDown={(event) => {if (event.key === "Enter") onPreview();}}
placeholder="folder/file.pdf or **/*.pdf" />
<Button onClick={onPreview} disabled={resolving || !draft.trim()}>
<Search size={15} aria-hidden="true" /> {resolving ? "Checking..." : "Preview"}
<Search size={15} aria-hidden="true" /> {resolving ? "i18n:govoplan-files.checking.494d0f68" : "i18n:govoplan-files.preview.f1fbb2b4"}
</Button>
</div>
</label>
{matchCount !== null && (
<div className="managed-pattern-summary">
<span>{matchCount} current file{matchCount === 1 ? "" : "s"} match.</span>
<Button variant="ghost" onClick={onBackToFolder}>Back to folder</Button>
{matchCount !== null &&
<div className="managed-pattern-summary">
<span>{matchCount} i18n:govoplan-files.current_file.1fbfcf3d{matchCount === 1 ? "" : "s"} i18n:govoplan-files.match.c79af5c2</span>
<Button variant="ghost" onClick={onBackToFolder}>i18n:govoplan-files.back_to_folder.34ba1ed1</Button>
</div>
)}
{patternIsRendered && <small className="managed-pattern-rendered">Preview resolves to <code>{renderedPattern}</code>.</small>}
</div>
);
}
{patternIsRendered && <small className="managed-pattern-rendered">i18n:govoplan-files.preview_resolves_to.a8d99432 <code>{renderedPattern}</code>.</small>}
</div>);
}
function VirtualChooserFolderTree({
@@ -572,16 +576,16 @@ function VirtualChooserFolderTree({
onOpen,
onToggle,
disabled
}: {
nodes: FolderNode[];
activeSpaceId: string;
spaceId: string;
currentFolder: string;
expandedKeys: Set<string>;
onOpen: (spaceId: string, path: string) => void;
onToggle: (spaceId: string, path: string) => void;
disabled?: boolean;
}) {
}: {nodes: FolderNode[];activeSpaceId: string;spaceId: string;currentFolder: string;expandedKeys: Set<string>;onOpen: (spaceId: string, path: string) => void;onToggle: (spaceId: string, path: string) => void;disabled?: boolean;}) {
const rows = useMemo(() => flattenVisibleFolderTree(nodes, spaceId, expandedKeys), [expandedKeys, nodes, spaceId]);
const resetKey = useMemo(
() => `${spaceId}:${currentFolder}:${rows.length}:${Array.from(expandedKeys).sort().join("|")}`,
@@ -600,12 +604,12 @@ function VirtualChooserFolderTree({
className="managed-file-tree-virtual-list"
ref={virtualRows.viewportRef}
role="tree"
aria-label="Folders"
onScroll={(event) => virtualRows.setScrollTop(event.currentTarget.scrollTop)}
>
{virtualRows.topSpacerHeight > 0 && (
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.topSpacerHeight }} aria-hidden="true" />
)}
aria-label="i18n:govoplan-files.folders.19adc47b"
onScroll={(event) => virtualRows.setScrollTop(event.currentTarget.scrollTop)}>
{virtualRows.topSpacerHeight > 0 &&
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.topSpacerHeight }} aria-hidden="true" />
}
{rows.slice(virtualRows.startIndex, virtualRows.endIndex).map(({ node, depth }, index) => {
const nodeId = treeNodeKey(spaceId, node.path);
const hasChildren = node.children.length > 0;
@@ -619,8 +623,8 @@ function VirtualChooserFolderTree({
style={{ paddingLeft: `${Math.min(depth * 14, 56)}px` }}
role="treeitem"
aria-expanded={hasChildren ? expanded : undefined}
aria-selected={active}
>
aria-selected={active}>
<button
type="button"
className="file-tree-toggle"
@@ -629,26 +633,26 @@ function VirtualChooserFolderTree({
if (hasChildren) onToggle(spaceId, node.path);
}}
disabled={disabled || !hasChildren}
aria-label={`${expanded ? "Collapse" : "Expand"} ${node.name}`}
>
aria-label={i18nMessage("i18n:govoplan-files.value_value.dca59cc0", { value0: expanded ? "i18n:govoplan-files.collapse.9cf188d3" : "i18n:govoplan-files.expand.9869e506", value1: node.name })}>
{expanded ? <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}
>
disabled={disabled}>
<span>{node.name}</span>
</button>
</div>
);
</div>);
})}
{virtualRows.bottomSpacerHeight > 0 && (
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.bottomSpacerHeight }} aria-hidden="true" />
)}
</div>
);
{virtualRows.bottomSpacerHeight > 0 &&
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.bottomSpacerHeight }} aria-hidden="true" />
}
</div>);
}
const ChooserFileBrowser = memo(function ChooserFileBrowser({
@@ -664,20 +668,20 @@ const ChooserFileBrowser = memo(function ChooserFileBrowser({
onOpenFolder,
onToggleSort,
onRequestExactFile
}: {
mode: ManagedFileChooserMode;
loading: boolean;
currentFolder: string;
effectiveRoot: string;
sortedEntries: ChooserExplorerEntry[];
sortColumn: SortColumn;
sortDirection: SortDirection;
linkTarget?: FilesManagedFileLinkTarget;
selectedExactPattern: string;
onOpenFolder: (path: string) => void;
onToggleSort: (column: SortColumn) => void;
onRequestExactFile: (file: ManagedFile) => void;
}) {
}: {mode: ManagedFileChooserMode;loading: boolean;currentFolder: string;effectiveRoot: string;sortedEntries: ChooserExplorerEntry[];sortColumn: SortColumn;sortDirection: SortDirection;linkTarget?: FilesManagedFileLinkTarget;selectedExactPattern: string;onOpenFolder: (path: string) => void;onToggleSort: (column: SortColumn) => void;onRequestExactFile: (file: ManagedFile) => void;}) {
const resetKey = `${mode}:${currentFolder}:${sortColumn}:${sortDirection}:${sortedEntries.length}`;
const virtualRows = useFixedVirtualRows<HTMLButtonElement>({
itemCount: sortedEntries.length,
@@ -696,14 +700,14 @@ const ChooserFileBrowser = memo(function ChooserFileBrowser({
className="managed-file-entry is-folder"
onClick={() => onOpenFolder(entry.path)}
aria-posinset={position}
aria-setsize={sortedEntries.length + 1}
>
aria-setsize={sortedEntries.length + 1}>
<Folder size={18} aria-hidden="true" />
<span className="managed-file-entry-name"><strong>{entry.name}</strong><small>{entry.fileCount} file(s), {entry.folderCount} folder(s)</small></span>
<span className="managed-file-entry-name"><strong>{entry.name}</strong><small>{entry.fileCount} i18n:govoplan-files.file_s.ca61e97f {entry.folderCount} i18n:govoplan-files.folder_s.10e54730</small></span>
<span className="managed-file-entry-size">{formatBytes(entry.totalSize)}</span>
<span className="managed-file-entry-modified">{formatDate(entry.updatedAt)}</span>
</button>
);
</button>);
}
const exactPattern = relativePath(entry.file.display_path, effectiveRoot);
const selected = mode === "attachment" && selectedExactPattern === exactPattern;
@@ -715,36 +719,36 @@ const ChooserFileBrowser = memo(function ChooserFileBrowser({
className={`managed-file-entry is-file ${selected ? "is-selected" : ""} ${mode === "folder" ? "is-context-only" : ""}`}
onClick={() => mode === "attachment" ? onRequestExactFile(entry.file) : undefined}
disabled={mode === "folder"}
aria-label={mode === "folder" ? `${entry.file.filename}, file shown for context` : entry.file.filename}
aria-label={mode === "folder" ? i18nMessage("i18n:govoplan-files.value_file_shown_for_context.3d6e0acb", { value0: entry.file.filename }) : entry.file.filename}
aria-posinset={position}
aria-setsize={sortedEntries.length + 1}
>
aria-setsize={sortedEntries.length + 1}>
<File size={18} aria-hidden="true" />
<span className="managed-file-entry-name">
<strong>{entry.file.filename}</strong>
<small>{linked ? <><Link2 size={12} aria-hidden="true" /> linked to {linkTarget?.label || "target"}</> : "File"}</small>
<small>{linked ? <><Link2 size={12} aria-hidden="true" /> i18n:govoplan-files.linked_to.ca188768 {linkTarget?.label || "target"}</> : "i18n:govoplan-files.file.2c3cafa4"}</small>
</span>
<span className="managed-file-entry-size">{formatBytes(entry.file.size_bytes)}</span>
<span className="managed-file-entry-modified">{formatDate(entry.file.updated_at)}</span>
</button>
);
</button>);
}
return (
<div className="managed-file-entry-table">
<div className="managed-file-entry-head" role="row">
<span aria-hidden="true" />
<ChooserSortButton column="name" label="Name" activeColumn={sortColumn} direction={sortDirection} onSort={onToggleSort} />
<ChooserSortButton column="size" label="Size" activeColumn={sortColumn} direction={sortDirection} onSort={onToggleSort} />
<ChooserSortButton column="modified" label="Modified" activeColumn={sortColumn} direction={sortDirection} onSort={onToggleSort} />
<ChooserSortButton column="name" label="i18n:govoplan-files.name.709a2322" activeColumn={sortColumn} direction={sortDirection} onSort={onToggleSort} />
<ChooserSortButton column="size" label="i18n:govoplan-files.size.b7152342" activeColumn={sortColumn} direction={sortDirection} onSort={onToggleSort} />
<ChooserSortButton column="modified" label="i18n:govoplan-files.modified.19a532c8" activeColumn={sortColumn} direction={sortDirection} onSort={onToggleSort} />
</div>
<div
className="managed-file-entry-list"
ref={virtualRows.viewportRef}
role="listbox"
aria-label={mode === "folder" ? "Folders and files" : "Managed files"}
onScroll={(event) => virtualRows.setScrollTop(event.currentTarget.scrollTop)}
>
aria-label={mode === "folder" ? "i18n:govoplan-files.folders_and_files.d107ac99" : "i18n:govoplan-files.managed_files.4dd6ad11"}
onScroll={(event) => virtualRows.setScrollTop(event.currentTarget.scrollTop)}>
<button
type="button"
ref={virtualRows.measureRowRef}
@@ -752,31 +756,31 @@ const ChooserFileBrowser = memo(function ChooserFileBrowser({
onClick={() => onOpenFolder(parentWithinRoot(currentFolder, effectiveRoot))}
disabled={loading || currentFolder === effectiveRoot}
aria-posinset={1}
aria-setsize={sortedEntries.length + 1}
>
aria-setsize={sortedEntries.length + 1}>
<FolderOpen size={18} aria-hidden="true" />
<span className="managed-file-entry-name"><strong>..</strong><small>Parent folder</small></span>
<span className="managed-file-entry-name"><strong>..</strong><small>i18n:govoplan-files.parent_folder.d8ed4c2a</small></span>
<span className="managed-file-entry-size">-</span>
<span className="managed-file-entry-modified">-</span>
</button>
{virtualRows.topSpacerHeight > 0 && (
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.topSpacerHeight }} aria-hidden="true" />
)}
{sortedEntries.slice(virtualRows.startIndex, virtualRows.endIndex).map((entry, index) => (
renderEntry(entry, virtualRows.startIndex + index + 2)
))}
{virtualRows.bottomSpacerHeight > 0 && (
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.bottomSpacerHeight }} aria-hidden="true" />
)}
{!loading && sortedEntries.length === 0 && (
<p className="managed-file-empty muted">This folder is empty. Upload files in the top-level Files module.</p>
{virtualRows.topSpacerHeight > 0 &&
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.topSpacerHeight }} aria-hidden="true" />
}
{sortedEntries.slice(virtualRows.startIndex, virtualRows.endIndex).map((entry, index) =>
renderEntry(entry, virtualRows.startIndex + index + 2)
)}
{virtualRows.bottomSpacerHeight > 0 &&
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.bottomSpacerHeight }} aria-hidden="true" />
}
{!loading && sortedEntries.length === 0 &&
<p className="managed-file-empty muted">i18n:govoplan-files.this_folder_is_empty_upload_files_in_the_top_lev.cb6c3a1a</p>
}
</div>
</div>
);
</div>);
});
function PatternResultList({ files, linkTarget, onChoose }: { files: ManagedFile[]; linkTarget?: FilesManagedFileLinkTarget; onChoose: (file: ManagedFile) => void }) {
function PatternResultList({ files, linkTarget, onChoose }: {files: ManagedFile[];linkTarget?: FilesManagedFileLinkTarget;onChoose: (file: ManagedFile) => void;}) {
const resetKey = `${files.length}:${files[0]?.id ?? ""}:${files[files.length - 1]?.id ?? ""}`;
const virtualRows = useFixedVirtualRows<HTMLButtonElement>({
itemCount: files.length,
@@ -785,45 +789,45 @@ function PatternResultList({ files, linkTarget, onChoose }: { files: ManagedFile
});
return (
<div className="managed-pattern-results" role="table" aria-label="Pattern preview results" aria-rowcount={files.length + 1}>
<div className="managed-pattern-results" role="table" aria-label="i18n:govoplan-files.pattern_preview_results.56cea56c" aria-rowcount={files.length + 1}>
<div className="file-list-table-head" role="row">
<span>Name</span><span>Size</span><span>Modified</span>
<span>i18n:govoplan-files.name.709a2322</span><span>i18n:govoplan-files.size.b7152342</span><span>i18n:govoplan-files.modified.19a532c8</span>
</div>
<div
className="file-list-table"
ref={virtualRows.viewportRef}
onScroll={(event) => virtualRows.setScrollTop(event.currentTarget.scrollTop)}
>
{files.length === 0 && <div className="file-list-empty">No current files match this pattern.</div>}
{virtualRows.topSpacerHeight > 0 && (
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.topSpacerHeight }} aria-hidden="true" />
)}
{files.slice(virtualRows.startIndex, virtualRows.endIndex).map((file, index) => (
<button
type="button"
className="file-list-row file-row managed-pattern-result-row"
role="row"
key={file.id}
ref={index === 0 ? virtualRows.measureRowRef : undefined}
onClick={() => onChoose(file)}
aria-rowindex={virtualRows.startIndex + index + 2}
>
onScroll={(event) => virtualRows.setScrollTop(event.currentTarget.scrollTop)}>
{files.length === 0 && <div className="file-list-empty">i18n:govoplan-files.no_current_files_match_this_pattern.b84c5836</div>}
{virtualRows.topSpacerHeight > 0 &&
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.topSpacerHeight }} aria-hidden="true" />
}
{files.slice(virtualRows.startIndex, virtualRows.endIndex).map((file, index) =>
<button
type="button"
className="file-list-row file-row managed-pattern-result-row"
role="row"
key={file.id}
ref={index === 0 ? virtualRows.measureRowRef : undefined}
onClick={() => onChoose(file)}
aria-rowindex={virtualRows.startIndex + index + 2}>
<span className="file-list-name-cell">
<span className="file-list-name">
<File className="file-row-icon" size={20} aria-hidden="true" />
<span><strong>{file.display_path}</strong>{isLinkedToTarget(file, linkTarget) && <small>Linked to {linkTarget?.label || "target"}</small>}</span>
<span><strong>{file.display_path}</strong>{isLinkedToTarget(file, linkTarget) && <small>i18n:govoplan-files.linked_to.e7ca9e02 {linkTarget?.label || "target"}</small>}</span>
</span>
</span>
<span>{formatBytes(file.size_bytes)}</span>
<span className="file-row-tail"><span>{formatDate(file.updated_at)}</span></span>
</button>
))}
{virtualRows.bottomSpacerHeight > 0 && (
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.bottomSpacerHeight }} aria-hidden="true" />
)}
{virtualRows.bottomSpacerHeight > 0 &&
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.bottomSpacerHeight }} aria-hidden="true" />
}
</div>
</div>
);
</div>);
}
function useFixedVirtualRows<T extends HTMLElement>({
@@ -832,13 +836,13 @@ function useFixedVirtualRows<T extends HTMLElement>({
defaultRowHeight,
rowGap = 0,
leadingRows = 0
}: {
itemCount: number;
resetKey: string;
defaultRowHeight: number;
rowGap?: number;
leadingRows?: number;
}) {
}: {itemCount: number;resetKey: string;defaultRowHeight: number;rowGap?: number;leadingRows?: number;}) {
const viewportRef = useRef<HTMLDivElement | null>(null);
const measureRowRef = useRef<T | null>(null);
const [viewportHeight, setViewportHeight] = useState(0);
@@ -917,12 +921,12 @@ function useFixedVirtualRows<T extends HTMLElement>({
}
function flattenVisibleFolderTree(
nodes: FolderNode[],
spaceId: string,
expandedKeys: Set<string>,
depth = 1,
rows: VirtualFolderTreeRow[] = []
): VirtualFolderTreeRow[] {
nodes: FolderNode[],
spaceId: string,
expandedKeys: Set<string>,
depth = 1,
rows: VirtualFolderTreeRow[] = [])
: VirtualFolderTreeRow[] {
for (const node of nodes) {
rows.push({ node, depth });
if (node.children.length > 0 && expandedKeys.has(treeNodeKey(spaceId, node.path))) {
@@ -944,7 +948,7 @@ function parseManagedFileSource(value: unknown): ManagedFileSource | null {
if (typeof value !== "string" || !value.startsWith(MANAGED_FILE_SOURCE_PREFIX)) return null;
const [, ownerType, ...ownerIdParts] = value.split(":");
const ownerId = ownerIdParts.join(":").trim();
if ((ownerType !== "user" && ownerType !== "group") || !ownerId) return null;
if (ownerType !== "user" && ownerType !== "group" || !ownerId) return null;
return { ownerType, ownerId };
}
@@ -961,12 +965,12 @@ function relativePath(path: string, root: string): string {
return normalizedPath.startsWith(prefix) ? normalizedPath.slice(prefix.length) : normalizedPath;
}
function chooserBreadcrumbs(folder: string, root: string): { name: string; path: string }[] {
function chooserBreadcrumbs(folder: string, root: string): {name: string;path: string;}[] {
const normalizedFolder = normalizeFolder(folder);
const normalizedRoot = normalizeFolder(root);
const relative = normalizedRoot && normalizedFolder.startsWith(`${normalizedRoot}/`)
? normalizedFolder.slice(normalizedRoot.length + 1)
: normalizedRoot === normalizedFolder ? "" : normalizedFolder;
const relative = normalizedRoot && normalizedFolder.startsWith(`${normalizedRoot}/`) ?
normalizedFolder.slice(normalizedRoot.length + 1) :
normalizedRoot === normalizedFolder ? "" : normalizedFolder;
const parts = relative.split("/").filter(Boolean);
return parts.map((name, index) => ({
name,
@@ -1009,10 +1013,10 @@ function isExactPattern(value: string): boolean {
}
function renderChooserPattern(
pattern: string,
previewContext?: Record<string, string>,
renderPatternPreview?: FilesManagedFileChooserProps["renderPatternPreview"]
): string {
pattern: string,
previewContext?: Record<string, string>,
renderPatternPreview?: FilesManagedFileChooserProps["renderPatternPreview"])
: string {
const trimmed = pattern.trim();
if (!trimmed) return trimmed;
return (renderPatternPreview?.(trimmed, previewContext) ?? trimmed).trim();
@@ -1038,10 +1042,10 @@ function writeRememberedState(key: string, state: RememberedChooserState) {
try {
window.localStorage.setItem(key, JSON.stringify(state));
} catch {
// Storage is optional; chooser behavior remains functional without it.
}
}
// Storage is optional; chooser behavior remains functional without it.
}}
function errorMessage(reason: unknown): string {
return reason instanceof Error ? reason.message : String(reason);
}

View File

@@ -4,7 +4,7 @@ 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 DialogKind = "upload" | "connector-sync" | "connector-space" | "create-folder" | "rename" | "single-rename" | "transfer" | null;
export type EntrySelectionKey = `file:${string}` | `folder:${string}`;
export type ContextMenuState = {
x: number;

View File

@@ -1,4 +1,4 @@
import { formatDateTime as formatPlatformDateTime } from "@govoplan/core-webui";
import { formatDateTime as formatPlatformDateTime, i18nMessage } from "@govoplan/core-webui";
import type { FileFolder, ManagedFile } from "../../../api/files";
import type { DragSelectionState, EntrySelectionKey, ExplorerEntry, FileEntry, FolderEntry, FolderNode, SortColumn, SortDirection } from "../types";
@@ -55,7 +55,7 @@ export function treeNodeKey(spaceId: string, folderPath: string): string {
return `${spaceId}:${normalizeFolder(folderPath)}`;
}
export function folderAncestorPaths(folderPath: string, options: { includeSelf?: boolean } = {}): string[] {
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[] = [];
@@ -134,7 +134,7 @@ export function sortExplorerEntries(entries: ExplorerEntry[], column: SortColumn
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);
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") {
@@ -163,7 +163,7 @@ export function entryModifiedTime(entry: ExplorerEntry): number {
return Number.isNaN(parsed) ? 0 : parsed;
}
export function directFolderCounts(files: ManagedFile[], folders: FileFolder[], folderPath: string): { files: number; folders: number } {
export function directFolderCounts(files: ManagedFile[], folders: FileFolder[], folderPath: string): {files: number;folders: number;} {
const stats = buildFolderStats(files, folders).get(normalizeFolder(folderPath));
return { files: stats?.directFiles ?? 0, folders: stats?.childFolders.size ?? 0 };
}
@@ -243,7 +243,7 @@ export function buildFolderEntryFromSources(files: ManagedFile[], folders: FileF
return {
kind: "folder",
id: `folder:${normalizedPath}`,
name: lastPathSegment(normalizedPath) || "Root",
name: lastPathSegment(normalizedPath) || "i18n:govoplan-files.root.e96857c5",
path: normalizedPath,
fileCount: counts.files,
folderCount: counts.folders,
@@ -271,7 +271,7 @@ export function fileIdsForSelection(files: ManagedFile[], selectedFileIds: Set<s
return Array.from(ids);
}
export function folderBreadcrumbs(folder: string): { name: string; path: string }[] {
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("/") }));
}
@@ -318,14 +318,19 @@ export function parseDragState(value: string): DragSelectionState | null {
}
export function formatBytes(value: number): string {
if (value < 1024) return `${value} B`;
const units = ["KB", "MB", "GB", "TB"];
if (value < 1024) return i18nMessage("i18n:govoplan-files.bytes_b", { value0: value });
const units = [
"i18n:govoplan-files.bytes_kb",
"i18n:govoplan-files.bytes_mb",
"i18n:govoplan-files.bytes_gb",
"i18n:govoplan-files.bytes_tb"
];
let size = value / 1024;
for (const unit of units) {
if (size < 1024) return `${size.toFixed(size >= 10 ? 0 : 1)} ${unit}`;
if (size < 1024) return i18nMessage(unit, { value0: size.toFixed(size >= 10 ? 0 : 1) });
size /= 1024;
}
return `${size.toFixed(1)} PB`;
return i18nMessage("i18n:govoplan-files.bytes_pb", { value0: size.toFixed(1) });
}
export function formatDate(value: string): string {