chore: sync GovOPlaN module split state
This commit is contained in:
1717
webui/src/features/files/FileConnectorSettingsPanel.tsx
Normal file
1717
webui/src/features/files/FileConnectorSettingsPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>);
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user