initial commit after split
This commit is contained in:
635
webui/src/features/campaigns/components/ManagedFileChooser.tsx
Normal file
635
webui/src/features/campaigns/components/ManagedFileChooser.tsx
Normal file
@@ -0,0 +1,635 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, File, Folder, FolderOpen, Home, Link2, Search } from "lucide-react";
|
||||
import type { ApiSettings } from "../../../types";
|
||||
import {
|
||||
listFileSpaces,
|
||||
listFiles,
|
||||
listFolders,
|
||||
resolveFilePatterns,
|
||||
shareFileWithCampaign,
|
||||
type FileFolder,
|
||||
type FileSpace,
|
||||
type ManagedFile
|
||||
} from "../../../api/files";
|
||||
import Button from "../../../components/Button";
|
||||
import ConfirmDialog from "../../../components/ConfirmDialog";
|
||||
import Dialog from "../../../components/Dialog";
|
||||
import DismissibleAlert from "../../../components/DismissibleAlert";
|
||||
import { FolderTree } from "../../files/components/FileManagerComponents";
|
||||
import { useFileTreeState } from "../../files/hooks/useFileTreeState";
|
||||
import type { FolderNode, SortColumn, SortDirection } from "../../files/types";
|
||||
import {
|
||||
buildExplorerEntries,
|
||||
buildFolderTree,
|
||||
formatBytes,
|
||||
formatDate,
|
||||
normalizeFilePath,
|
||||
normalizeFolder,
|
||||
parentFolderPath,
|
||||
sortExplorerEntries
|
||||
} from "../../files/utils/fileManagerUtils";
|
||||
import {
|
||||
encodeManagedAttachmentSource,
|
||||
parseManagedAttachmentSource,
|
||||
type ManagedAttachmentSource
|
||||
} from "../utils/attachments";
|
||||
|
||||
type ManagedFileChooserMode = "folder" | "attachment";
|
||||
type AttachmentChoiceMode = "file" | "pattern";
|
||||
|
||||
type RememberedChooserState = {
|
||||
spaceId?: string;
|
||||
folder?: string;
|
||||
pattern?: string;
|
||||
};
|
||||
|
||||
export type ManagedFolderSelection = {
|
||||
space: FileSpace;
|
||||
folderPath: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type ManagedAttachmentSelection = ManagedFolderSelection & {
|
||||
fileFilter: string;
|
||||
matchCount: number;
|
||||
fileIds: string[];
|
||||
selectionType: AttachmentChoiceMode;
|
||||
};
|
||||
|
||||
type ManagedFileChooserProps = {
|
||||
open: boolean;
|
||||
settings: ApiSettings;
|
||||
campaignId: string;
|
||||
mode: ManagedFileChooserMode;
|
||||
source?: string;
|
||||
basePath?: string;
|
||||
initialPattern?: string;
|
||||
rememberKey?: string;
|
||||
onClose: () => void;
|
||||
onSelectFolder?: (selection: ManagedFolderSelection) => void;
|
||||
onSelectAttachment?: (selection: ManagedAttachmentSelection) => void;
|
||||
};
|
||||
|
||||
export default function ManagedFileChooser({
|
||||
open,
|
||||
settings,
|
||||
campaignId,
|
||||
mode,
|
||||
source,
|
||||
basePath = ".",
|
||||
initialPattern = "",
|
||||
rememberKey = mode,
|
||||
onClose,
|
||||
onSelectFolder,
|
||||
onSelectAttachment
|
||||
}: ManagedFileChooserProps) {
|
||||
const parsedSource = useMemo(() => parseManagedAttachmentSource(source), [source]);
|
||||
const normalizedBasePath = normalizeManagedBasePath(basePath);
|
||||
const storageKey = useMemo(
|
||||
() => `multimailer.managedFileChooser.${campaignId}.${rememberKey}`,
|
||||
[campaignId, rememberKey]
|
||||
);
|
||||
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 [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
|
||||
? !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 { expandedTreeNodes, toggleTreeFolder } = useFileTreeState({
|
||||
activeSpaceId: selectedSpaceId,
|
||||
currentFolder,
|
||||
onOpenFolder: (_spaceId, path) => openFolder(path)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setPattern(remembered.pattern ?? initialPattern.trim());
|
||||
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; };
|
||||
}, [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 Promise.all([
|
||||
listFiles(settings, { owner_type: selectedSpace.owner_type, owner_id: selectedSpace.owner_id }),
|
||||
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; };
|
||||
}, [effectiveRoot, open, selectedSpace?.id, settings.apiBaseUrl, settings.apiKey, settings.accessToken, storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !selectedSpaceId) return;
|
||||
writeRememberedState(storageKey, { spaceId: selectedSpaceId, folder: currentFolder, pattern });
|
||||
}, [currentFolder, open, pattern, selectedSpaceId, storageKey]);
|
||||
|
||||
function toggleSort(column: SortColumn) {
|
||||
if (sortColumn === column) {
|
||||
setSortDirection((current) => current === "asc" ? "desc" : "asc");
|
||||
return;
|
||||
}
|
||||
setSortColumn(column);
|
||||
setSortDirection(column === "name" ? "asc" : "desc");
|
||||
}
|
||||
|
||||
function openFolder(path: string) {
|
||||
const normalized = normalizeFolder(path);
|
||||
if (mode === "attachment" && effectiveRoot && !isWithinRoot(normalized, effectiveRoot)) return;
|
||||
setCurrentFolder(normalized);
|
||||
setPatternMatches(null);
|
||||
}
|
||||
|
||||
async function previewPattern(): Promise<ManagedFile[]> {
|
||||
if (!selectedSpace) return [];
|
||||
const trimmed = pattern.trim();
|
||||
if (!trimmed) {
|
||||
setPatternMatches([]);
|
||||
return [];
|
||||
}
|
||||
setResolving(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await resolveFilePatterns(settings, {
|
||||
patterns: [trimmed],
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function requestExactFile(file: ManagedFile) {
|
||||
const exactPattern = relativePath(file.display_path, effectiveRoot);
|
||||
const currentPattern = pattern.trim();
|
||||
if (currentPattern && currentPattern !== exactPattern) {
|
||||
setPendingExactFile(file);
|
||||
return;
|
||||
}
|
||||
applyExactFile(file);
|
||||
}
|
||||
|
||||
function applyExactFile(file: ManagedFile) {
|
||||
setPattern(relativePath(file.display_path, effectiveRoot));
|
||||
setPatternMatches([file]);
|
||||
setPendingExactFile(null);
|
||||
}
|
||||
|
||||
async function shareMatches(matches: ManagedFile[]) {
|
||||
const toShare = matches.filter((file) => !file.shares?.some((share) => (
|
||||
share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at
|
||||
)));
|
||||
await Promise.all(toShare.map((file) => shareFileWithCampaign(settings, file.id, campaignId)));
|
||||
}
|
||||
|
||||
async function confirmSelection() {
|
||||
if (!selectedSpace || !sourceMatchesSpace) return;
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
if (mode === "folder") {
|
||||
onSelectFolder?.({
|
||||
space: selectedSpace,
|
||||
folderPath: currentFolder,
|
||||
source: encodeManagedAttachmentSource(selectedSpace)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = patternMatches ?? await previewPattern();
|
||||
await shareMatches(matches);
|
||||
const trimmedPattern = pattern.trim();
|
||||
onSelectAttachment?.({
|
||||
space: selectedSpace,
|
||||
folderPath: effectiveRoot,
|
||||
source: encodeManagedAttachmentSource(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" ? "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>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void confirmSelection()}
|
||||
disabled={loading || submitting || !selectedSpace || !sourceMatchesSpace || (mode === "attachment" && (!parsedSource || !pattern.trim()))}
|
||||
>
|
||||
{submitting ? "Linking…" : mode === "folder" ? "Use this folder" : "Use pattern"}
|
||||
</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.
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
{mode === "attachment" && parsedSource && !sourceMatchesSpace && !loading && (
|
||||
<DismissibleAlert tone="danger" dismissible={false}>The configured managed file space is no longer available to this user.</DismissibleAlert>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<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" 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 && (
|
||||
<FolderTree
|
||||
nodes={treeNodes}
|
||||
activeSpaceId={selectedSpaceId}
|
||||
spaceId={space.id}
|
||||
currentFolder={currentFolder}
|
||||
expandedKeys={expandedTreeNodes}
|
||||
onOpen={(_spaceId, path) => openFolder(path)}
|
||||
onToggle={toggleTreeFolder}
|
||||
dragDropEnabled={false}
|
||||
contextMenuEnabled={false}
|
||||
disabled={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!loading && spaces.length === 0 && <p className="muted small-note">No accessible file spaces were found.</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">
|
||||
<button type="button" onClick={() => openFolder(effectiveRoot)} disabled={loading}>
|
||||
<Home size={14} aria-hidden="true" /> {selectedSpace?.label || "Files"}
|
||||
</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" && (
|
||||
<div className="managed-pattern-editor">
|
||||
<label>
|
||||
<span>File or pattern relative to <code>{effectiveRoot || "."}</code></span>
|
||||
<div className="field-with-action split-field-action">
|
||||
<input
|
||||
value={pattern}
|
||||
onChange={(event) => { setPattern(event.target.value); setPatternMatches(null); }}
|
||||
onKeyDown={(event) => { if (event.key === "Enter") void previewPattern(); }}
|
||||
placeholder="folder/file.pdf or **/*.pdf"
|
||||
/>
|
||||
<Button onClick={() => void previewPattern()} disabled={resolving || !pattern.trim()}>
|
||||
<Search size={15} aria-hidden="true" /> {resolving ? "Checking…" : "Preview"}
|
||||
</Button>
|
||||
</div>
|
||||
</label>
|
||||
{patternMatches !== null && (
|
||||
<div className="managed-pattern-summary">
|
||||
<span>{patternMatches.length} current file{patternMatches.length === 1 ? "" : "s"} match.</span>
|
||||
<Button variant="ghost" onClick={() => setPatternMatches(null)}>Back to folder</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{patternMatches !== null && mode === "attachment" ? (
|
||||
<PatternResultList files={patternMatches} campaignId={campaignId} onChoose={requestExactFile} />
|
||||
) : (
|
||||
<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={toggleSort} />
|
||||
<ChooserSortButton column="size" label="Size" activeColumn={sortColumn} direction={sortDirection} onSort={toggleSort} />
|
||||
<ChooserSortButton column="modified" label="Modified" activeColumn={sortColumn} direction={sortDirection} onSort={toggleSort} />
|
||||
</div>
|
||||
<div className="managed-file-entry-list" role="listbox" aria-label={mode === "folder" ? "Folders and files" : "Managed files"}>
|
||||
<button
|
||||
type="button"
|
||||
className="managed-file-entry is-folder is-parent"
|
||||
onClick={() => openFolder(parentWithinRoot(currentFolder, effectiveRoot))}
|
||||
disabled={loading || currentFolder === effectiveRoot}
|
||||
>
|
||||
<FolderOpen size={18} aria-hidden="true" />
|
||||
<span className="managed-file-entry-name"><strong>..</strong><small>Parent folder</small></span>
|
||||
<span className="managed-file-entry-size">—</span>
|
||||
<span className="managed-file-entry-modified">—</span>
|
||||
</button>
|
||||
{sortedEntries.map((entry) => {
|
||||
if (entry.kind === "folder") {
|
||||
return (
|
||||
<button type="button" key={entry.id} className="managed-file-entry is-folder" onClick={() => openFolder(entry.path)}>
|
||||
<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-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" && pattern.trim() === exactPattern;
|
||||
const campaignShared = isSharedWithCampaign(entry.file, campaignId);
|
||||
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" ? requestExactFile(entry.file) : undefined}
|
||||
disabled={mode === "folder"}
|
||||
aria-label={mode === "folder" ? `${entry.file.filename}, file shown for context` : entry.file.filename}
|
||||
>
|
||||
<File size={18} aria-hidden="true" />
|
||||
<span className="managed-file-entry-name">
|
||||
<strong>{entry.file.filename}</strong>
|
||||
<small>{campaignShared ? <><Link2 size={12} aria-hidden="true" /> linked to campaign</> : "File"}</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>
|
||||
);
|
||||
})}
|
||||
{!loading && sortedEntries.length === 0 && (
|
||||
<p className="managed-file-empty muted">This folder is empty. Upload files in the top-level Files module.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(pendingExactFile)}
|
||||
title="Replace the current pattern?"
|
||||
message={pendingExactFile ? `Replace “${pattern.trim()}” with the exact file “${relativePath(pendingExactFile.display_path, effectiveRoot)}”?` : "Replace the current pattern?"}
|
||||
confirmLabel="Replace pattern"
|
||||
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={`${label}: ${active ? `sorted ${direction === "asc" ? "ascending" : "descending"}` : "not sorted"}. Activate to sort.`}
|
||||
onClick={() => onSort(column)}
|
||||
>
|
||||
<span>{label}</span><Icon size={14} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PatternResultList({ files, campaignId, onChoose }: { files: ManagedFile[]; campaignId: string; onChoose: (file: ManagedFile) => void }) {
|
||||
return (
|
||||
<div className="managed-pattern-results" role="table" aria-label="Pattern preview results">
|
||||
<div className="file-list-table-head" role="row">
|
||||
<span>Name</span><span>Size</span><span>Modified</span>
|
||||
</div>
|
||||
<div className="file-list-table">
|
||||
{files.length === 0 && <div className="file-list-empty">No current files match this pattern.</div>}
|
||||
{files.map((file) => (
|
||||
<button type="button" className="file-list-row file-row managed-pattern-result-row" role="row" key={file.id} onClick={() => onChoose(file)}>
|
||||
<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>{isSharedWithCampaign(file, campaignId) && <small>Linked to campaign</small>}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span>{formatBytes(file.size_bytes)}</span>
|
||||
<span className="file-row-tail"><span>{formatDate(file.updated_at)}</span></span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sourceMatches(space: FileSpace, source: ManagedAttachmentSource | null): boolean {
|
||||
return Boolean(source && space.owner_type === source.ownerType && space.owner_id === source.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 isSharedWithCampaign(file: ManagedFile, campaignId: string): boolean {
|
||||
return Boolean(file.shares?.some((share) => share.target_type === "campaign" && share.target_id === campaignId && !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);
|
||||
}
|
||||
Reference in New Issue
Block a user