1052 lines
41 KiB
TypeScript
1052 lines
41 KiB
TypeScript
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { ArrowDown, ArrowUp, ArrowUpDown, File, Folder, FolderOpen, Home, Link2, Search } from "lucide-react";
|
|
import {
|
|
listFileSpaces,
|
|
listManagedFileSnapshot,
|
|
resolveFilePatterns,
|
|
shareFilesWithTarget,
|
|
type FileFolder,
|
|
type FileSpace,
|
|
type ManagedFile } from
|
|
"../../../api/files";
|
|
import {
|
|
Button,
|
|
ConfirmDialog,
|
|
Dialog,
|
|
DismissibleAlert,
|
|
LoadingFrame,
|
|
type FilesManagedAttachmentSelection,
|
|
type FilesManagedFileChooserProps,
|
|
type FilesManagedFileLinkTarget,
|
|
type FilesManagedFolderSelection, i18nMessage } from
|
|
"@govoplan/core-webui";
|
|
import { useFileTreeState } from "../hooks/useFileTreeState";
|
|
import type { FolderNode, SortColumn, SortDirection } from "../types";
|
|
import {
|
|
buildExplorerEntries,
|
|
buildFolderTree,
|
|
formatBytes,
|
|
formatDate,
|
|
normalizeFilePath,
|
|
normalizeFolder,
|
|
parentFolderPath,
|
|
sortExplorerEntries,
|
|
treeNodeKey } from
|
|
"../utils/fileManagerUtils";
|
|
|
|
type ManagedFileChooserMode = FilesManagedFileChooserProps["mode"];
|
|
|
|
const DEFAULT_CHOOSER_FILE_ROW_HEIGHT = 52;
|
|
const DEFAULT_PATTERN_RESULT_ROW_HEIGHT = 58;
|
|
const DEFAULT_CHOOSER_TREE_ROW_HEIGHT = 32;
|
|
const CHOOSER_FILE_ROW_GAP = 4;
|
|
const CHOOSER_FILE_OVERSCAN_ROWS = 8;
|
|
|
|
type RememberedChooserState = {
|
|
spaceId?: string;
|
|
folder?: string;
|
|
pattern?: string;
|
|
};
|
|
|
|
type ManagedFileSource = {
|
|
ownerType: "user" | "group";
|
|
ownerId: string;
|
|
};
|
|
|
|
const MANAGED_FILE_SOURCE_PREFIX = "managed:";
|
|
|
|
type ChooserExplorerEntry = ReturnType<typeof buildExplorerEntries>[number];
|
|
type VirtualFolderTreeRow = {node: FolderNode;depth: number;};
|
|
|
|
export type ManagedFolderSelection = FilesManagedFolderSelection;
|
|
export type ManagedAttachmentSelection = FilesManagedAttachmentSelection;
|
|
|
|
export default function ManagedFileChooser({
|
|
open,
|
|
settings,
|
|
mode,
|
|
storageScope,
|
|
source,
|
|
basePath = ".",
|
|
initialPattern = "",
|
|
rememberKey = mode,
|
|
previewContext,
|
|
linkTarget,
|
|
renderPatternPreview,
|
|
onClose,
|
|
onSelectFolder,
|
|
onSelectAttachment
|
|
}: FilesManagedFileChooserProps) {
|
|
const parsedSource = useMemo(() => parseManagedFileSource(source), [source]);
|
|
const normalizedBasePath = normalizeManagedBasePath(basePath);
|
|
const storageKey = useMemo(
|
|
() => `govoplan.files.managedFileChooser.${storageScope || linkTarget?.id || "global"}.${rememberKey}`,
|
|
[linkTarget?.id, rememberKey, storageScope]
|
|
);
|
|
const remembered = useMemo(() => readRememberedState(storageKey), [storageKey, open]);
|
|
const [spaces, setSpaces] = useState<FileSpace[]>([]);
|
|
const [selectedSpaceId, setSelectedSpaceId] = useState("");
|
|
const [files, setFiles] = useState<ManagedFile[]>([]);
|
|
const [folders, setFolders] = useState<FileFolder[]>([]);
|
|
const [currentFolder, setCurrentFolder] = useState("");
|
|
const [pattern, setPattern] = useState("");
|
|
const patternRef = useRef("");
|
|
const rememberDraftTimerRef = useRef<number | null>(null);
|
|
const [patternHasText, setPatternHasText] = useState(false);
|
|
const [exactSelectionPattern, setExactSelectionPattern] = useState("");
|
|
const [patternMatches, setPatternMatches] = useState<ManagedFile[] | null>(null);
|
|
const [pendingExactFile, setPendingExactFile] = useState<ManagedFile | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [resolving, setResolving] = useState(false);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState("");
|
|
|
|
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 effectiveRoot = mode === "attachment" ? normalizedBasePath : "";
|
|
const entries = useMemo(
|
|
() => buildExplorerEntries(files, folders, currentFolder, false),
|
|
[currentFolder, files, folders]
|
|
);
|
|
const [sortColumn, setSortColumn] = useState<SortColumn>("name");
|
|
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
|
|
const sortedEntries = useMemo(
|
|
() => sortExplorerEntries(entries, sortColumn, sortDirection),
|
|
[entries, sortColumn, sortDirection]
|
|
);
|
|
const breadcrumbs = chooserBreadcrumbs(currentFolder, effectiveRoot);
|
|
const allTreeNodes = useMemo(() => buildFolderTree(files, folders), [files, folders]);
|
|
const treeNodes = useMemo(() => nodesWithinRoot(allTreeNodes, effectiveRoot), [allTreeNodes, effectiveRoot]);
|
|
const openFolder = useCallback((path: string) => {
|
|
const normalized = normalizeFolder(path);
|
|
if (mode === "attachment" && effectiveRoot && !isWithinRoot(normalized, effectiveRoot)) return;
|
|
setCurrentFolder(normalized);
|
|
setPatternMatches(null);
|
|
}, [effectiveRoot, mode]);
|
|
const { expandedTreeNodes, toggleTreeFolder } = useFileTreeState({
|
|
activeSpaceId: selectedSpaceId,
|
|
currentFolder,
|
|
onOpenFolder: (_spaceId, path) => openFolder(path)
|
|
});
|
|
|
|
const toggleSort = useCallback((column: SortColumn) => {
|
|
if (sortColumn === column) {
|
|
setSortDirection((current) => current === "asc" ? "desc" : "asc");
|
|
return;
|
|
}
|
|
setSortColumn(column);
|
|
setSortDirection(column === "name" ? "asc" : "desc");
|
|
}, [sortColumn]);
|
|
|
|
const applyExactFile = useCallback((file: ManagedFile) => {
|
|
const exactPattern = relativePath(file.display_path, effectiveRoot);
|
|
patternRef.current = exactPattern;
|
|
setPattern(exactPattern);
|
|
setPatternHasText(true);
|
|
setExactSelectionPattern(exactPattern);
|
|
setPatternMatches([file]);
|
|
setPendingExactFile(null);
|
|
}, [effectiveRoot]);
|
|
|
|
const scheduleRememberedState = useCallback((patternValue = patternRef.current) => {
|
|
if (!open || !selectedSpaceId) return;
|
|
if (rememberDraftTimerRef.current !== null) window.clearTimeout(rememberDraftTimerRef.current);
|
|
rememberDraftTimerRef.current = window.setTimeout(() => {
|
|
writeRememberedState(storageKey, { spaceId: selectedSpaceId, folder: currentFolder, pattern: patternValue });
|
|
rememberDraftTimerRef.current = null;
|
|
}, 350);
|
|
}, [currentFolder, open, selectedSpaceId, storageKey]);
|
|
|
|
useEffect(() => () => {
|
|
if (rememberDraftTimerRef.current !== null) window.clearTimeout(rememberDraftTimerRef.current);
|
|
}, []);
|
|
|
|
const requestExactFile = useCallback((file: ManagedFile) => {
|
|
const exactPattern = relativePath(file.display_path, effectiveRoot);
|
|
const currentPattern = patternRef.current.trim();
|
|
if (currentPattern && currentPattern !== exactPattern) {
|
|
setPendingExactFile(file);
|
|
return;
|
|
}
|
|
applyExactFile(file);
|
|
}, [applyExactFile, effectiveRoot]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
setError("");
|
|
const initialPatternValue = remembered.pattern ?? initialPattern.trim();
|
|
patternRef.current = initialPatternValue;
|
|
setPattern(initialPatternValue);
|
|
setPatternHasText(Boolean(initialPatternValue.trim()));
|
|
setExactSelectionPattern("");
|
|
setPatternMatches(null);
|
|
setPendingExactFile(null);
|
|
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(() => {
|
|
if (!open || !selectedSpace) return;
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
setError("");
|
|
setPatternMatches(null);
|
|
const rememberedFolder = selectedSpace.id === remembered.spaceId ? normalizeFolder(remembered.folder || "") : "";
|
|
const initialFolder = rememberedFolder && isWithinRoot(rememberedFolder, effectiveRoot) ? rememberedFolder : effectiveRoot;
|
|
setCurrentFolder(initialFolder || effectiveRoot);
|
|
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(() => {
|
|
scheduleRememberedState();
|
|
}, [currentFolder, pattern, scheduleRememberedState]);
|
|
|
|
function updatePatternInput(value: string) {
|
|
patternRef.current = value;
|
|
setPatternHasText((current) => {
|
|
const next = Boolean(value.trim());
|
|
return current === next ? current : next;
|
|
});
|
|
setExactSelectionPattern("");
|
|
if (patternMatches !== null) setPatternMatches(null);
|
|
scheduleRememberedState(value);
|
|
}
|
|
|
|
async function previewPattern(): Promise<ManagedFile[]> {
|
|
if (!selectedSpace) return [];
|
|
const trimmed = patternRef.current.trim();
|
|
if (!trimmed) {
|
|
setPatternMatches([]);
|
|
setPattern("");
|
|
setPatternHasText(false);
|
|
return [];
|
|
}
|
|
const previewPatternValue = renderChooserPattern(trimmed, previewContext, renderPatternPreview);
|
|
if (!previewPatternValue) {
|
|
setPatternMatches([]);
|
|
setPattern(trimmed);
|
|
return [];
|
|
}
|
|
setPattern(trimmed);
|
|
setPatternHasText(true);
|
|
setResolving(true);
|
|
setError("");
|
|
try {
|
|
const response = await resolveFilePatterns(settings, {
|
|
patterns: [previewPatternValue],
|
|
owner_type: selectedSpace.owner_type,
|
|
owner_id: selectedSpace.owner_id,
|
|
path_prefix: effectiveRoot || undefined,
|
|
include_unmatched: false
|
|
});
|
|
const matches = response.patterns[0]?.matches ?? [];
|
|
setPatternMatches(matches);
|
|
return matches;
|
|
} catch (reason) {
|
|
setError(errorMessage(reason));
|
|
return [];
|
|
} finally {
|
|
setResolving(false);
|
|
}
|
|
}
|
|
|
|
async function shareMatches(matches: ManagedFile[]) {
|
|
if (!linkTarget) return;
|
|
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);
|
|
}
|
|
|
|
async function confirmSelection() {
|
|
if (!selectedSpace || !sourceMatchesSpace) return;
|
|
setSubmitting(true);
|
|
setError("");
|
|
try {
|
|
if (mode === "folder") {
|
|
onSelectFolder?.({
|
|
space: selectedSpace,
|
|
folderPath: currentFolder,
|
|
source: encodeManagedFileSource(selectedSpace)
|
|
});
|
|
return;
|
|
}
|
|
|
|
const matches = patternMatches ?? (await previewPattern());
|
|
await shareMatches(matches);
|
|
const trimmedPattern = patternRef.current.trim();
|
|
onSelectAttachment?.({
|
|
space: selectedSpace,
|
|
folderPath: effectiveRoot,
|
|
source: encodeManagedFileSource(selectedSpace),
|
|
fileFilter: trimmedPattern,
|
|
matchCount: matches.length,
|
|
fileIds: matches.map((file) => file.id),
|
|
selectionType: isExactPattern(trimmedPattern) ? "file" : "pattern"
|
|
});
|
|
} catch (reason) {
|
|
setError(errorMessage(reason));
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
if (!open || typeof document === "undefined") return null;
|
|
|
|
const dialog =
|
|
<>
|
|
<Dialog
|
|
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 ? "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}>
|
|
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}>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="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}>
|
|
<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}>
|
|
|
|
{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">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="i18n:govoplan-files.current_folder.5aeab2f0">
|
|
<button type="button" onClick={() => openFolder(effectiveRoot)} disabled={loading}>
|
|
<Home size={14} aria-hidden="true" /> {selectedSpace?.label || "i18n:govoplan-files.files.6ce6c512"}
|
|
</button>
|
|
{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)} />
|
|
|
|
}
|
|
|
|
{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="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);
|
|
}
|
|
|
|
function ChooserSortButton({
|
|
column,
|
|
label,
|
|
activeColumn,
|
|
direction,
|
|
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;
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={active ? "is-sorted" : ""}
|
|
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>);
|
|
|
|
}
|
|
|
|
function ManagedPatternEditor({
|
|
value,
|
|
effectiveRoot,
|
|
resolving,
|
|
matchCount,
|
|
previewContext,
|
|
renderPatternPreview,
|
|
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;}) {
|
|
const [draft, setDraft] = useState(value);
|
|
const renderedPattern = useMemo(() => renderChooserPattern(draft, previewContext, renderPatternPreview), [draft, previewContext, renderPatternPreview]);
|
|
const patternIsRendered = Boolean(draft.trim() && renderedPattern && renderedPattern !== draft.trim());
|
|
|
|
useEffect(() => {
|
|
setDraft(value);
|
|
}, [value]);
|
|
|
|
function updateDraft(next: string) {
|
|
setDraft(next);
|
|
onDraftChange(next);
|
|
}
|
|
|
|
return (
|
|
<div className="managed-pattern-editor">
|
|
<label>
|
|
<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" />
|
|
|
|
<Button onClick={onPreview} disabled={resolving || !draft.trim()}>
|
|
<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} 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">i18n:govoplan-files.preview_resolves_to.a8d99432 <code>{renderedPattern}</code>.</small>}
|
|
</div>);
|
|
|
|
}
|
|
|
|
function VirtualChooserFolderTree({
|
|
nodes,
|
|
activeSpaceId,
|
|
spaceId,
|
|
currentFolder,
|
|
expandedKeys,
|
|
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;}) {
|
|
const rows = useMemo(() => flattenVisibleFolderTree(nodes, spaceId, expandedKeys), [expandedKeys, nodes, spaceId]);
|
|
const resetKey = useMemo(
|
|
() => `${spaceId}:${currentFolder}:${rows.length}:${Array.from(expandedKeys).sort().join("|")}`,
|
|
[currentFolder, expandedKeys, rows.length, spaceId]
|
|
);
|
|
const virtualRows = useFixedVirtualRows<HTMLDivElement>({
|
|
itemCount: rows.length,
|
|
resetKey,
|
|
defaultRowHeight: DEFAULT_CHOOSER_TREE_ROW_HEIGHT
|
|
});
|
|
|
|
if (rows.length === 0) return null;
|
|
|
|
return (
|
|
<div
|
|
className="managed-file-tree-virtual-list"
|
|
ref={virtualRows.viewportRef}
|
|
role="tree"
|
|
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;
|
|
const expanded = expandedKeys.has(nodeId);
|
|
const active = activeSpaceId === spaceId && treeNodeKey(spaceId, currentFolder) === nodeId;
|
|
return (
|
|
<div
|
|
key={nodeId}
|
|
ref={index === 0 ? virtualRows.measureRowRef : undefined}
|
|
className={`file-tree-node-wrap ${active ? "is-active" : ""}`}
|
|
style={{ paddingLeft: `${Math.min(depth * 14, 56)}px` }}
|
|
role="treeitem"
|
|
aria-expanded={hasChildren ? expanded : undefined}
|
|
aria-selected={active}>
|
|
|
|
<button
|
|
type="button"
|
|
className="file-tree-toggle"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
if (hasChildren) onToggle(spaceId, node.path);
|
|
}}
|
|
disabled={disabled || !hasChildren}
|
|
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}>
|
|
|
|
<span>{node.name}</span>
|
|
</button>
|
|
</div>);
|
|
|
|
})}
|
|
{virtualRows.bottomSpacerHeight > 0 &&
|
|
<div className="managed-file-virtual-spacer" style={{ height: virtualRows.bottomSpacerHeight }} aria-hidden="true" />
|
|
}
|
|
</div>);
|
|
|
|
}
|
|
|
|
const ChooserFileBrowser = memo(function ChooserFileBrowser({
|
|
mode,
|
|
loading,
|
|
currentFolder,
|
|
effectiveRoot,
|
|
sortedEntries,
|
|
sortColumn,
|
|
sortDirection,
|
|
linkTarget,
|
|
selectedExactPattern,
|
|
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;}) {
|
|
const resetKey = `${mode}:${currentFolder}:${sortColumn}:${sortDirection}:${sortedEntries.length}`;
|
|
const virtualRows = useFixedVirtualRows<HTMLButtonElement>({
|
|
itemCount: sortedEntries.length,
|
|
resetKey,
|
|
rowGap: CHOOSER_FILE_ROW_GAP,
|
|
leadingRows: 1,
|
|
defaultRowHeight: DEFAULT_CHOOSER_FILE_ROW_HEIGHT
|
|
});
|
|
|
|
function renderEntry(entry: ChooserExplorerEntry, position: number) {
|
|
if (entry.kind === "folder") {
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={entry.id}
|
|
className="managed-file-entry is-folder"
|
|
onClick={() => onOpenFolder(entry.path)}
|
|
aria-posinset={position}
|
|
aria-setsize={sortedEntries.length + 1}>
|
|
|
|
<Folder size={18} aria-hidden="true" />
|
|
<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>);
|
|
|
|
}
|
|
const exactPattern = relativePath(entry.file.display_path, effectiveRoot);
|
|
const selected = mode === "attachment" && selectedExactPattern === exactPattern;
|
|
const linked = isLinkedToTarget(entry.file, linkTarget);
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={entry.file.id}
|
|
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" ? 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}>
|
|
|
|
<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" /> 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>);
|
|
|
|
}
|
|
|
|
return (
|
|
<div className="managed-file-entry-table">
|
|
<div className="managed-file-entry-head" role="row">
|
|
<span aria-hidden="true" />
|
|
<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" ? "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}
|
|
className="managed-file-entry is-folder is-parent"
|
|
onClick={() => onOpenFolder(parentWithinRoot(currentFolder, effectiveRoot))}
|
|
disabled={loading || currentFolder === effectiveRoot}
|
|
aria-posinset={1}
|
|
aria-setsize={sortedEntries.length + 1}>
|
|
|
|
<FolderOpen size={18} aria-hidden="true" />
|
|
<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">i18n:govoplan-files.this_folder_is_empty_upload_files_in_the_top_lev.cb6c3a1a</p>
|
|
}
|
|
</div>
|
|
</div>);
|
|
|
|
});
|
|
|
|
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,
|
|
resetKey,
|
|
defaultRowHeight: DEFAULT_PATTERN_RESULT_ROW_HEIGHT
|
|
});
|
|
|
|
return (
|
|
<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>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">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>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" />
|
|
}
|
|
</div>
|
|
</div>);
|
|
|
|
}
|
|
|
|
function useFixedVirtualRows<T extends HTMLElement>({
|
|
itemCount,
|
|
resetKey,
|
|
defaultRowHeight,
|
|
rowGap = 0,
|
|
leadingRows = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
}: {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);
|
|
const [scrollTop, setScrollTop] = useState(0);
|
|
const [rowHeight, setRowHeight] = useState(defaultRowHeight);
|
|
|
|
const virtualRows = useMemo(() => {
|
|
const rowExtent = Math.max(1, rowHeight + rowGap);
|
|
const itemScrollTop = Math.max(0, scrollTop - leadingRows * rowExtent);
|
|
const effectiveViewportHeight = Math.max(viewportHeight, rowExtent * CHOOSER_FILE_OVERSCAN_ROWS);
|
|
const rawStartIndex = Math.max(0, Math.floor(itemScrollTop / rowExtent) - CHOOSER_FILE_OVERSCAN_ROWS);
|
|
const startIndex = Math.min(itemCount, rawStartIndex);
|
|
const endIndex = Math.min(
|
|
itemCount,
|
|
Math.max(startIndex, Math.ceil((itemScrollTop + effectiveViewportHeight) / rowExtent) + CHOOSER_FILE_OVERSCAN_ROWS)
|
|
);
|
|
|
|
return {
|
|
startIndex,
|
|
endIndex,
|
|
topSpacerHeight: startIndex * rowExtent,
|
|
bottomSpacerHeight: Math.max(0, (itemCount - endIndex) * rowExtent)
|
|
};
|
|
}, [itemCount, leadingRows, rowGap, rowHeight, scrollTop, viewportHeight]);
|
|
|
|
useEffect(() => {
|
|
const viewport = viewportRef.current;
|
|
if (!viewport) return undefined;
|
|
|
|
const updateViewportHeight = () => {
|
|
setViewportHeight(viewport.clientHeight);
|
|
setScrollTop(viewport.scrollTop);
|
|
};
|
|
|
|
updateViewportHeight();
|
|
if (typeof ResizeObserver === "undefined") return undefined;
|
|
|
|
const observer = new ResizeObserver(updateViewportHeight);
|
|
observer.observe(viewport);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const row = measureRowRef.current;
|
|
if (!row) {
|
|
setRowHeight(defaultRowHeight);
|
|
return undefined;
|
|
}
|
|
|
|
const updateRowHeight = () => {
|
|
const measuredHeight = row.getBoundingClientRect().height;
|
|
if (measuredHeight > 0) setRowHeight(Math.round(measuredHeight));
|
|
};
|
|
|
|
updateRowHeight();
|
|
if (typeof ResizeObserver === "undefined") return undefined;
|
|
|
|
const observer = new ResizeObserver(updateRowHeight);
|
|
observer.observe(row);
|
|
return () => observer.disconnect();
|
|
}, [defaultRowHeight, resetKey]);
|
|
|
|
useEffect(() => {
|
|
const viewport = viewportRef.current;
|
|
if (!viewport) return;
|
|
viewport.scrollTop = 0;
|
|
setScrollTop(0);
|
|
}, [resetKey]);
|
|
|
|
return {
|
|
...virtualRows,
|
|
viewportRef,
|
|
measureRowRef,
|
|
setScrollTop
|
|
};
|
|
}
|
|
|
|
function flattenVisibleFolderTree(
|
|
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))) {
|
|
flattenVisibleFolderTree(node.children, spaceId, expandedKeys, depth + 1, rows);
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function sourceMatches(space: FileSpace, source: ManagedFileSource | null): boolean {
|
|
return Boolean(source && space.owner_type === source.ownerType && space.owner_id === source.ownerId);
|
|
}
|
|
|
|
function encodeManagedFileSource(space: Pick<FileSpace, "owner_type" | "owner_id">): string {
|
|
return `${MANAGED_FILE_SOURCE_PREFIX}${space.owner_type}:${space.owner_id}`;
|
|
}
|
|
|
|
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;
|
|
return { ownerType, ownerId };
|
|
}
|
|
|
|
function normalizeManagedBasePath(value: string): string {
|
|
const normalized = normalizeFolder(value);
|
|
return normalized === "." ? "" : normalized;
|
|
}
|
|
|
|
function relativePath(path: string, root: string): string {
|
|
const normalizedPath = normalizeFilePath(path);
|
|
const normalizedRoot = normalizeFolder(root);
|
|
if (!normalizedRoot) return normalizedPath;
|
|
const prefix = `${normalizedRoot}/`;
|
|
return normalizedPath.startsWith(prefix) ? normalizedPath.slice(prefix.length) : normalizedPath;
|
|
}
|
|
|
|
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 parts = relative.split("/").filter(Boolean);
|
|
return parts.map((name, index) => ({
|
|
name,
|
|
path: normalizeFolder([normalizedRoot, ...parts.slice(0, index + 1)].filter(Boolean).join("/"))
|
|
}));
|
|
}
|
|
|
|
function parentWithinRoot(folder: string, root: string): string {
|
|
const normalizedFolder = normalizeFolder(folder);
|
|
const normalizedRoot = normalizeFolder(root);
|
|
if (!normalizedFolder || normalizedFolder === normalizedRoot) return normalizedRoot;
|
|
const parent = parentFolderPath(normalizedFolder);
|
|
return isWithinRoot(parent, normalizedRoot) ? parent : normalizedRoot;
|
|
}
|
|
|
|
function isWithinRoot(path: string, root: string): boolean {
|
|
const normalizedPath = normalizeFolder(path);
|
|
const normalizedRoot = normalizeFolder(root);
|
|
return !normalizedRoot || normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`);
|
|
}
|
|
|
|
function nodesWithinRoot(nodes: FolderNode[], root: string): FolderNode[] {
|
|
const normalizedRoot = normalizeFolder(root);
|
|
if (!normalizedRoot) return nodes;
|
|
const node = findNode(nodes, normalizedRoot);
|
|
return node?.children ?? [];
|
|
}
|
|
|
|
function findNode(nodes: FolderNode[], path: string): FolderNode | null {
|
|
for (const node of nodes) {
|
|
if (node.path === path) return node;
|
|
const child = findNode(node.children, path);
|
|
if (child) return child;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isExactPattern(value: string): boolean {
|
|
return Boolean(value) && !/[?*\[\]{}]/.test(value);
|
|
}
|
|
|
|
function renderChooserPattern(
|
|
pattern: string,
|
|
previewContext?: Record<string, string>,
|
|
renderPatternPreview?: FilesManagedFileChooserProps["renderPatternPreview"])
|
|
: string {
|
|
const trimmed = pattern.trim();
|
|
if (!trimmed) return trimmed;
|
|
return (renderPatternPreview?.(trimmed, previewContext) ?? trimmed).trim();
|
|
}
|
|
|
|
function isLinkedToTarget(file: ManagedFile, target?: FilesManagedFileLinkTarget): boolean {
|
|
if (!target) return false;
|
|
return Boolean(file.shares?.some((share) => share.target_type === target.type && share.target_id === target.id && !share.revoked_at));
|
|
}
|
|
|
|
function readRememberedState(key: string): RememberedChooserState {
|
|
if (typeof window === "undefined") return {};
|
|
try {
|
|
const parsed = JSON.parse(window.localStorage.getItem(key) || "{}") as RememberedChooserState;
|
|
return parsed && typeof parsed === "object" ? parsed : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function writeRememberedState(key: string, state: RememberedChooserState) {
|
|
if (typeof window === "undefined") return;
|
|
try {
|
|
window.localStorage.setItem(key, JSON.stringify(state));
|
|
} catch {
|
|
|
|
|
|
// Storage is optional; chooser behavior remains functional without it.
|
|
}}
|
|
function errorMessage(reason: unknown): string {
|
|
return reason instanceof Error ? reason.message : String(reason);
|
|
}
|