FileChooser in Attachments
This commit is contained in:
@@ -341,10 +341,14 @@ export default function DataGrid<T>({
|
|||||||
className={`data-grid-cell data-grid-header-cell ${column.headerClassName ?? ""} ${column.sortable ? "is-sortable" : ""} ${sorted ? "is-sorted" : ""} ${stickyClass(column)}`.trim()}
|
className={`data-grid-cell data-grid-header-cell ${column.headerClassName ?? ""} ${column.sortable ? "is-sortable" : ""} ${sorted ? "is-sorted" : ""} ${stickyClass(column)}`.trim()}
|
||||||
style={stickyStyle(column, stickyOffsets[columnIndex])}
|
style={stickyStyle(column, stickyOffsets[columnIndex])}
|
||||||
>
|
>
|
||||||
<button type="button" className="data-grid-header-button" disabled={!column.sortable} onClick={() => toggleSort(column)}>
|
{column.sortable ? (
|
||||||
|
<button type="button" className="data-grid-header-button" onClick={() => toggleSort(column)}>
|
||||||
<span>{column.header}</span>
|
<span>{column.header}</span>
|
||||||
{column.sortable && <SortIcon direction={sorted} />}
|
<SortIcon direction={sorted} />
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="data-grid-header-label"><span>{column.header}</span></div>
|
||||||
|
)}
|
||||||
{column.filterable && (
|
{column.filterable && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
|
import { listFileSpaces, type FileSpace } from "../../api/files";
|
||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import PageTitle from "../../components/PageTitle";
|
import PageTitle from "../../components/PageTitle";
|
||||||
@@ -15,13 +15,15 @@ import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
|||||||
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||||
import { updateNested } from "./utils/draftEditor";
|
import { updateNested } from "./utils/draftEditor";
|
||||||
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
|
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
|
||||||
import { countIndividualAttachmentRules, createAttachmentBasePath, createAttachmentRule, ensureAttachmentBasePaths, mockAttachmentPathOptions, normalizeAttachmentBasePaths, normalizeAttachmentRules, summarizeAttachmentRules, type AttachmentBasePath } from "./utils/attachments";
|
import ManagedFileChooser from "./components/ManagedFileChooser";
|
||||||
|
import { countIndividualAttachmentRules, createAttachmentBasePath, createAttachmentRule, ensureAttachmentBasePaths, nextAttachmentLabel, normalizeAttachmentBasePaths, normalizeAttachmentRules, parseManagedAttachmentSource, summarizeAttachmentRules, type AttachmentBasePath } from "./utils/attachments";
|
||||||
|
|
||||||
type PathChooserState = { index: number };
|
type PathChooserState = { index: number };
|
||||||
|
|
||||||
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||||
const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
|
const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
|
||||||
|
const [fileSpaces, setFileSpaces] = useState<FileSpace[]>([]);
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version);
|
const locked = isAuditLockedVersion(version);
|
||||||
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
|
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
|
||||||
@@ -41,7 +43,13 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
|
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
|
||||||
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
|
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void listFileSpaces(settings)
|
||||||
|
.then((response) => { if (!cancelled) setFileSpaces(response.spaces); })
|
||||||
|
.catch(() => { if (!cancelled) setFileSpaces([]); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||||
|
|
||||||
function patchBasePaths(paths: AttachmentBasePath[]) {
|
function patchBasePaths(paths: AttachmentBasePath[]) {
|
||||||
if (locked) return;
|
if (locked) return;
|
||||||
@@ -67,7 +75,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
|
|
||||||
function addGlobalAttachmentRule() {
|
function addGlobalAttachmentRule() {
|
||||||
if (locked) return;
|
if (locked) return;
|
||||||
patch(["attachments", "global"], [...globalRules, createAttachmentRule(basePaths[0]?.path ?? "")]);
|
patch(["attachments", "global"], [...globalRules, createAttachmentRule(basePaths[0]?.path ?? "", nextAttachmentLabel(globalRules))]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -99,7 +107,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
<DataGrid
|
<DataGrid
|
||||||
id={`campaign-${campaignId}-attachment-sources`}
|
id={`campaign-${campaignId}-attachment-sources`}
|
||||||
rows={basePaths}
|
rows={basePaths}
|
||||||
columns={attachmentSourceColumns({ locked, basePaths, patchBasePath, removeBasePath, setPathChooser })}
|
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, removeBasePath, setPathChooser })}
|
||||||
getRowKey={(basePath) => basePath.id}
|
getRowKey={(basePath) => basePath.id}
|
||||||
emptyText="No attachment sources configured."
|
emptyText="No attachment sources configured."
|
||||||
className="attachment-sources-table-wrap attachment-sources-table"
|
className="attachment-sources-table-wrap attachment-sources-table"
|
||||||
@@ -116,6 +124,8 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
disabled={locked}
|
disabled={locked}
|
||||||
emptyText="No global attachments are configured yet. Add files here only if every message should include them."
|
emptyText="No global attachments are configured yet. Add files here only if every message should include them."
|
||||||
basePaths={basePaths}
|
basePaths={basePaths}
|
||||||
|
settings={settings}
|
||||||
|
campaignId={campaignId}
|
||||||
onChange={(rules) => patch(["attachments", "global"], rules)}
|
onChange={(rules) => patch(["attachments", "global"], rules)}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -137,10 +147,24 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
|
|
||||||
{pathChooser && (
|
{pathChooser && (
|
||||||
<MockPathChooserOverlay
|
<ManagedFileChooser
|
||||||
|
open
|
||||||
|
settings={settings}
|
||||||
|
campaignId={campaignId}
|
||||||
|
mode="folder"
|
||||||
|
source={basePaths[pathChooser.index]?.source}
|
||||||
|
basePath={basePaths[pathChooser.index]?.path}
|
||||||
onClose={() => setPathChooser(null)}
|
onClose={() => setPathChooser(null)}
|
||||||
onSelect={(path) => {
|
onSelectFolder={(selection) => {
|
||||||
patchBasePath(pathChooser.index, path);
|
const current = basePaths[pathChooser.index];
|
||||||
|
const folderLabel = selection.folderPath ? selection.folderPath.split("/").filter(Boolean).slice(-1)[0] : selection.space.label;
|
||||||
|
patchBasePath(pathChooser.index, {
|
||||||
|
source: selection.source,
|
||||||
|
path: selection.folderPath || ".",
|
||||||
|
name: !current?.name || current.name === "New attachment source" || current.name === "Campaign files"
|
||||||
|
? `${selection.space.label}${selection.folderPath ? ` / ${folderLabel}` : ""}`
|
||||||
|
: current.name
|
||||||
|
});
|
||||||
setPathChooser(null);
|
setPathChooser(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -152,12 +176,13 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
|||||||
type AttachmentSourceColumnContext = {
|
type AttachmentSourceColumnContext = {
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
basePaths: AttachmentBasePath[];
|
basePaths: AttachmentBasePath[];
|
||||||
|
fileSpaces: FileSpace[];
|
||||||
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
|
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
|
||||||
removeBasePath: (index: number) => void;
|
removeBasePath: (index: number) => void;
|
||||||
setPathChooser: (state: PathChooserState | null) => void;
|
setPathChooser: (state: PathChooserState | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function attachmentSourceColumns({ locked, basePaths, patchBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
|
function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
|
||||||
return [
|
return [
|
||||||
{ id: "name", header: "Name", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="Campaign files" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name },
|
{ id: "name", header: "Name", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="Campaign files" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name },
|
||||||
{
|
{
|
||||||
@@ -168,10 +193,10 @@ function attachmentSourceColumns({ locked, basePaths, patchBasePath, removeBaseP
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
render: (basePath, index) => (
|
render: (basePath, index) => (
|
||||||
<div className="field-with-action">
|
<div className="field-with-action split-field-action">
|
||||||
<input
|
<input
|
||||||
className="chooser-display-input"
|
className="chooser-display-input"
|
||||||
value={basePath.path}
|
value={formatAttachmentSourcePath(basePath, fileSpaces)}
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -184,40 +209,24 @@ function attachmentSourceColumns({ locked, basePaths, patchBasePath, removeBaseP
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose</Button>
|
<Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose folder</Button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
value: (basePath) => basePath.path
|
value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces)
|
||||||
},
|
},
|
||||||
{ id: "individual", header: "Individual attachments", width: 260, sortable: true, filterable: true, render: (basePath, index) => <ToggleSwitch label="Individual" checked={Boolean(basePath.allow_individual)} disabled={locked} onChange={(checked) => patchBasePath(index, { allow_individual: checked })} />, value: (basePath) => basePath.allow_individual ? "individual" : "global only" },
|
{ id: "individual", header: "Individual attachments", width: 260, sortable: true, filterable: true, render: (basePath, index) => <ToggleSwitch label="Individual" checked={Boolean(basePath.allow_individual)} disabled={locked} onChange={(checked) => patchBasePath(index, { allow_individual: checked })} />, value: (basePath) => basePath.allow_individual ? "individual" : "global only" },
|
||||||
{ id: "unsent_warning", header: "Unsent warning", width: 200, sortable: true, filterable: true, render: (basePath, index) => <ToggleSwitch label="Unsent" checked={Boolean(basePath.unsent_warning)} disabled={locked} onChange={(checked) => patchBasePath(index, { unsent_warning: checked })} />, value: (basePath) => basePath.unsent_warning ? "warn" : "off" },
|
{ id: "unsent_warning", header: "Unsent warning", width: 200, sortable: true, filterable: true, render: (basePath, index) => <ToggleSwitch label="Unsent" checked={Boolean(basePath.unsent_warning)} disabled={locked} onChange={(checked) => patchBasePath(index, { unsent_warning: checked })} />, value: (basePath) => basePath.unsent_warning ? "warn" : "off" },
|
||||||
{ id: "actions", header: "Actions", width: 120, sticky: "end", render: (_basePath, index) => <Button variant="danger" onClick={() => removeBasePath(index)} disabled={locked || basePaths.length <= 1}>Remove</Button> }
|
{ id: "actions", header: "Actions", width: 120, sticky: "end", render: (_basePath, index) => <Button variant="danger" onClick={() => removeBasePath(index)} disabled={locked || basePaths.length <= 1}>Remove</Button> }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
function formatAttachmentSourcePath(basePath: AttachmentBasePath, spaces: FileSpace[]): string {
|
||||||
function MockPathChooserOverlay({ onClose, onSelect }: { onClose: () => void; onSelect: (path: Partial<AttachmentBasePath>) => void }) {
|
const parsedSource = parseManagedAttachmentSource(basePath.source);
|
||||||
return createPortal(
|
const matchingSpace = parsedSource
|
||||||
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="mock-path-chooser-title">
|
? spaces.find((space) => space.owner_type === parsedSource.ownerType && space.owner_id === parsedSource.ownerId)
|
||||||
<div className="modal-panel attachment-rules-modal">
|
: undefined;
|
||||||
<header className="modal-header">
|
const rootLabel = matchingSpace?.label
|
||||||
<h2 id="mock-path-chooser-title">Choose attachment base path</h2>
|
|| (parsedSource?.ownerType === "user" ? "My files" : parsedSource?.ownerType === "group" ? "Group files" : "");
|
||||||
<button className="modal-close" onClick={onClose}>×</button>
|
const relativePath = basePath.path.trim().replace(/^\.\/?$/, "").replace(/^\/+|\/+$/g, "");
|
||||||
</header>
|
if (rootLabel) return `${rootLabel}/${relativePath ? `${relativePath}/` : ""}`;
|
||||||
<div className="modal-body attachment-rules-body">
|
return `${relativePath || "."}/`;
|
||||||
<p className="muted small-note">Mock chooser for now. Later this will browse uploaded directories in the available campaign, group, tenant or user spaces.</p>
|
|
||||||
<div className="placeholder-stack">
|
|
||||||
{mockAttachmentPathOptions.map((path) => (
|
|
||||||
<Button key={path.path} onClick={() => onSelect(path)}>
|
|
||||||
{path.label}: <code>{path.path}</code>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<footer className="modal-footer">
|
|
||||||
<Button onClick={onClose}>Cancel</Button>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
|||||||
<DataGrid
|
<DataGrid
|
||||||
id={`campaign-${campaignId}-recipient-data`}
|
id={`campaign-${campaignId}-recipient-data`}
|
||||||
rows={inlineEntries.slice(0, 100)}
|
rows={inlineEntries.slice(0, 100)}
|
||||||
columns={recipientDataColumns({ locked, fieldDefinitions, individualAttachmentBasePaths, updateEntryAttachments, updateEntryField })}
|
columns={recipientDataColumns({ settings, campaignId, locked, fieldDefinitions, individualAttachmentBasePaths, updateEntryAttachments, updateEntryField })}
|
||||||
getRowKey={(entry, index) => String(entry.id || index)}
|
getRowKey={(entry, index) => String(entry.id || index)}
|
||||||
emptyText="No recipient data found."
|
emptyText="No recipient data found."
|
||||||
className="recipient-table-wrap recipient-data-table-wrap recipient-data-table"
|
className="recipient-table-wrap recipient-data-table-wrap recipient-data-table"
|
||||||
@@ -114,6 +114,8 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RecipientDataColumnContext = {
|
type RecipientDataColumnContext = {
|
||||||
|
settings: ApiSettings;
|
||||||
|
campaignId: string;
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
fieldDefinitions: ReturnType<typeof getDraftFields>;
|
fieldDefinitions: ReturnType<typeof getDraftFields>;
|
||||||
individualAttachmentBasePaths: ReturnType<typeof getIndividualAttachmentBasePaths>;
|
individualAttachmentBasePaths: ReturnType<typeof getIndividualAttachmentBasePaths>;
|
||||||
@@ -121,7 +123,7 @@ type RecipientDataColumnContext = {
|
|||||||
updateEntryField: (index: number, field: string, value: unknown) => void;
|
updateEntryField: (index: number, field: string, value: unknown) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function recipientDataColumns({ locked, fieldDefinitions, individualAttachmentBasePaths, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
function recipientDataColumns({ settings, campaignId, locked, fieldDefinitions, individualAttachmentBasePaths, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
||||||
return [
|
return [
|
||||||
{ id: "number", header: "#", width: 70, sortable: true, sticky: "start", render: (_entry, index) => <span className="mono-small recipient-index-cell">{index + 1}</span>, value: (_entry, index) => index + 1 },
|
{ id: "number", header: "#", width: 70, sortable: true, sticky: "start", render: (_entry, index) => <span className="mono-small recipient-index-cell">{index + 1}</span>, value: (_entry, index) => index + 1 },
|
||||||
{
|
{
|
||||||
@@ -151,7 +153,10 @@ function recipientDataColumns({ locked, fieldDefinitions, individualAttachmentBa
|
|||||||
<AttachmentRulesOverlay
|
<AttachmentRulesOverlay
|
||||||
title={`Attachments for recipient ${index + 1}`}
|
title={`Attachments for recipient ${index + 1}`}
|
||||||
rules={attachments}
|
rules={attachments}
|
||||||
|
settings={settings}
|
||||||
|
campaignId={campaignId}
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
|
buttonLabel={`entries: ${attachments.length}`}
|
||||||
basePaths={individualAttachmentBasePaths}
|
basePaths={individualAttachmentBasePaths}
|
||||||
onChange={(rules) => updateEntryAttachments(index, rules)}
|
onChange={(rules) => updateEntryAttachments(index, rules)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import type { ApiSettings } from "../../../types";
|
||||||
import Button from "../../../components/Button";
|
import Button from "../../../components/Button";
|
||||||
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
|
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
|
||||||
import ToggleSwitch from "../../../components/ToggleSwitch";
|
import ToggleSwitch from "../../../components/ToggleSwitch";
|
||||||
import { getBool, getText } from "../utils/draftEditor";
|
import { getBool, getText } from "../utils/draftEditor";
|
||||||
import { createAttachmentRule, mockAttachmentFiles, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule } from "../utils/attachments";
|
import { createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule } from "../utils/attachments";
|
||||||
|
import ManagedFileChooser, { type ManagedAttachmentSelection } from "./ManagedFileChooser";
|
||||||
|
|
||||||
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
|
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
|
||||||
|
|
||||||
type AttachmentRulesOverlayProps = {
|
type AttachmentRulesOverlayProps = {
|
||||||
title: string;
|
title: string;
|
||||||
rules: AttachmentRule[];
|
rules: AttachmentRule[];
|
||||||
|
settings: ApiSettings;
|
||||||
|
campaignId: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
buttonLabel?: string;
|
buttonLabel?: string;
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
@@ -20,6 +24,8 @@ type AttachmentRulesOverlayProps = {
|
|||||||
|
|
||||||
type AttachmentRulesTableProps = {
|
type AttachmentRulesTableProps = {
|
||||||
rules: AttachmentRule[];
|
rules: AttachmentRule[];
|
||||||
|
settings: ApiSettings;
|
||||||
|
campaignId: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
basePaths?: AttachmentBasePath[];
|
basePaths?: AttachmentBasePath[];
|
||||||
@@ -32,12 +38,14 @@ type AttachmentRulesTableProps = {
|
|||||||
|
|
||||||
type FileChooserState = {
|
type FileChooserState = {
|
||||||
ruleIndex: number;
|
ruleIndex: number;
|
||||||
basePath: string;
|
basePath: AttachmentBasePath | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AttachmentRulesOverlay({
|
export default function AttachmentRulesOverlay({
|
||||||
title,
|
title,
|
||||||
rules,
|
rules,
|
||||||
|
settings,
|
||||||
|
campaignId,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
buttonLabel,
|
buttonLabel,
|
||||||
emptyText = "No attachment files or matching rules configured yet.",
|
emptyText = "No attachment files or matching rules configured yet.",
|
||||||
@@ -45,70 +53,39 @@ export default function AttachmentRulesOverlay({
|
|||||||
onChange
|
onChange
|
||||||
}: AttachmentRulesOverlayProps) {
|
}: AttachmentRulesOverlayProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
|
|
||||||
const summary = useMemo(() => summarizeAttachmentRules(rules), [rules]);
|
const summary = useMemo(() => summarizeAttachmentRules(rules), [rules]);
|
||||||
const label = buttonLabel ?? `direct: ${summary.direct} / rules: ${summary.rules}`;
|
const label = buttonLabel ?? `direct: ${summary.direct} / rules: ${summary.rules}`;
|
||||||
|
|
||||||
function patchRule(index: number, patch: Partial<AttachmentRule>) {
|
|
||||||
onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
|
|
||||||
}
|
|
||||||
|
|
||||||
function openFileChooser(ruleIndex: number) {
|
|
||||||
const rule = rules[ruleIndex] ?? {};
|
|
||||||
setFileChooser({ ruleIndex, basePath: getText(rule, "base_dir", basePaths[0]?.path ?? "") });
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectFileFilter(fileFilter: string) {
|
|
||||||
if (!fileChooser) return;
|
|
||||||
patchRule(fileChooser.ruleIndex, { file_filter: fileFilter });
|
|
||||||
setFileChooser(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeOverlay() {
|
function closeOverlay() {
|
||||||
setFileChooser(null);
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeRule = fileChooser ? rules[fileChooser.ruleIndex] : undefined;
|
function addOverlayRule() {
|
||||||
const activeBasePath = fileChooser
|
onChange([...rules, createAttachmentRule(basePaths[0]?.path ?? "", nextAttachmentLabel(rules))]);
|
||||||
? getText(activeRule ?? {}, "base_dir", fileChooser.basePath || basePaths[0]?.path || "")
|
}
|
||||||
: "";
|
|
||||||
|
|
||||||
const dialog = open ? createPortal(
|
const dialog = open ? createPortal(
|
||||||
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="attachment-rules-title">
|
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="attachment-rules-title">
|
||||||
<div className="modal-panel attachment-rules-modal">
|
<div className="modal-panel attachment-rules-modal">
|
||||||
<header className="modal-header">
|
<header className="modal-header">
|
||||||
<h2 id="attachment-rules-title">{fileChooser ? "Choose file or pattern" : title}</h2>
|
<h2 id="attachment-rules-title">{title}</h2>
|
||||||
<button className="modal-close" onClick={closeOverlay}>×</button>
|
<button className="modal-close" onClick={closeOverlay}>×</button>
|
||||||
</header>
|
</header>
|
||||||
<div className="modal-body attachment-rules-body">
|
<div className="modal-body attachment-rules-body">
|
||||||
{fileChooser ? (
|
<AttachmentRulesDataGrid
|
||||||
<MockFileChooserContent
|
|
||||||
basePath={activeBasePath}
|
|
||||||
onSelect={selectFileFilter}
|
|
||||||
onClose={() => setFileChooser(null)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="muted small-note">Use direct files for fixed attachments and rules/patterns for files resolved during build.</p>
|
|
||||||
<AttachmentRulesTable
|
|
||||||
rules={rules}
|
rules={rules}
|
||||||
|
settings={settings}
|
||||||
|
campaignId={campaignId}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
emptyText={emptyText}
|
emptyText={emptyText}
|
||||||
basePaths={basePaths}
|
basePaths={basePaths}
|
||||||
activeChooserRuleIndex={null}
|
activeChooserRuleIndex={null}
|
||||||
onOpenFileChooser={openFileChooser}
|
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<footer className="modal-footer">
|
<footer className="modal-footer">
|
||||||
{fileChooser ? (
|
<Button variant="primary" onClick={addOverlayRule} disabled={disabled}>Add file</Button>
|
||||||
<Button onClick={() => setFileChooser(null)}>Back to rules</Button>
|
<Button onClick={closeOverlay}>Close</Button>
|
||||||
) : (
|
|
||||||
<Button variant="primary" onClick={closeOverlay}>Close</Button>
|
|
||||||
)}
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
@@ -131,7 +108,10 @@ export function AttachmentRulesTable({
|
|||||||
...tableProps
|
...tableProps
|
||||||
}: AttachmentRulesTableProps) {
|
}: AttachmentRulesTableProps) {
|
||||||
function addRule() {
|
function addRule() {
|
||||||
onChange([...tableProps.rules, createAttachmentRule(tableProps.basePaths?.[0]?.path ?? "")]);
|
onChange([
|
||||||
|
...tableProps.rules,
|
||||||
|
createAttachmentRule(tableProps.basePaths?.[0]?.path ?? "", nextAttachmentLabel(tableProps.rules))
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -140,7 +120,7 @@ export function AttachmentRulesTable({
|
|||||||
<AttachmentRulesDataGrid {...tableProps} onChange={onChange} />
|
<AttachmentRulesDataGrid {...tableProps} onChange={onChange} />
|
||||||
{showAddButton && (
|
{showAddButton && (
|
||||||
<div className="button-row compact-actions attachment-rules-footer-actions">
|
<div className="button-row compact-actions attachment-rules-footer-actions">
|
||||||
<Button onClick={addRule} disabled={tableProps.disabled}>Add file</Button>
|
<Button variant="primary" onClick={addRule} disabled={tableProps.disabled}>Add file</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -150,6 +130,8 @@ export function AttachmentRulesTable({
|
|||||||
|
|
||||||
export function AttachmentRulesDataGrid({
|
export function AttachmentRulesDataGrid({
|
||||||
rules,
|
rules,
|
||||||
|
settings,
|
||||||
|
campaignId,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
emptyText = "No attachment files or matching rules configured yet.",
|
emptyText = "No attachment files or matching rules configured yet.",
|
||||||
basePaths = [],
|
basePaths = [],
|
||||||
@@ -157,7 +139,7 @@ export function AttachmentRulesDataGrid({
|
|||||||
activeChooserRuleIndex = null,
|
activeChooserRuleIndex = null,
|
||||||
onOpenFileChooser,
|
onOpenFileChooser,
|
||||||
onChange
|
onChange
|
||||||
}: Omit<AttachmentRulesTableProps, "showAddButton">) {
|
}: AttachmentRulesTableProps) {
|
||||||
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
|
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
|
||||||
|
|
||||||
function patchRule(index: number, patch: Partial<AttachmentRule>) {
|
function patchRule(index: number, patch: Partial<AttachmentRule>) {
|
||||||
@@ -175,25 +157,26 @@ export function AttachmentRulesDataGrid({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rule = rules[ruleIndex] ?? {};
|
const rule = rules[ruleIndex] ?? {};
|
||||||
setFileChooser({ ruleIndex, basePath: getText(rule, "base_dir", basePaths[0]?.path ?? "") });
|
const currentPath = getText(rule, "base_dir", basePaths[0]?.path ?? "");
|
||||||
|
const basePath = basePaths.find((item) => item.path === currentPath) ?? basePaths[0] ?? null;
|
||||||
|
setFileChooser({ ruleIndex, basePath });
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectFileFilter(fileFilter: string) {
|
function selectAttachment(selection: ManagedAttachmentSelection) {
|
||||||
if (!fileChooser) return;
|
if (!fileChooser) return;
|
||||||
patchRule(fileChooser.ruleIndex, { file_filter: fileFilter });
|
const currentRule = rules[fileChooser.ruleIndex] ?? {};
|
||||||
|
patchRule(fileChooser.ruleIndex, {
|
||||||
|
base_dir: fileChooser.basePath?.path ?? (selection.folderPath || "."),
|
||||||
|
file_filter: selection.fileFilter,
|
||||||
|
type: selection.selectionType === "file" ? "direct" : "pattern",
|
||||||
|
include_subdirs: false,
|
||||||
|
label: getText(currentRule, "label") || `Attachment ${fileChooser.ruleIndex + 1}`
|
||||||
|
});
|
||||||
setFileChooser(null);
|
setFileChooser(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeRule = fileChooser ? rules[fileChooser.ruleIndex] : undefined;
|
|
||||||
const activeBasePath = fileChooser
|
|
||||||
? getText(activeRule ?? {}, "base_dir", fileChooser.basePath || basePaths[0]?.path || "")
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{rules.length === 0 ? (
|
|
||||||
<p className="muted attachment-rules-empty">{emptyText}</p>
|
|
||||||
) : (
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id={id}
|
id={id}
|
||||||
rows={rules}
|
rows={rules}
|
||||||
@@ -203,12 +186,18 @@ export function AttachmentRulesDataGrid({
|
|||||||
className="attachment-rules-table-wrap attachment-rules-table"
|
className="attachment-rules-table-wrap attachment-rules-table"
|
||||||
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined}
|
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{fileChooser && (
|
{fileChooser && (
|
||||||
<MockFileChooserOverlay
|
<ManagedFileChooser
|
||||||
basePath={activeBasePath}
|
open
|
||||||
onSelect={selectFileFilter}
|
settings={settings}
|
||||||
|
campaignId={campaignId}
|
||||||
|
mode="attachment"
|
||||||
|
source={fileChooser.basePath?.source}
|
||||||
|
basePath={fileChooser.basePath?.path ?? "."}
|
||||||
|
initialPattern={getText(rules[fileChooser.ruleIndex], "file_filter")}
|
||||||
|
rememberKey={`${id}:${String(rules[fileChooser.ruleIndex]?.id ?? fileChooser.ruleIndex)}`}
|
||||||
onClose={() => setFileChooser(null)}
|
onClose={() => setFileChooser(null)}
|
||||||
|
onSelectAttachment={selectAttachment}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -253,14 +242,14 @@ function attachmentRuleColumns({ disabled, basePaths, activeChooserRuleIndex: _a
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
render: (rule, index) => (
|
render: (rule, index) => (
|
||||||
<div className="field-with-action">
|
<div className="field-with-action split-field-action">
|
||||||
<input
|
<input
|
||||||
className="chooser-display-input"
|
className="chooser-display-input"
|
||||||
value={getText(rule, "file_filter")}
|
value={getText(rule, "file_filter")}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
placeholder="file.pdf or {{local:id}}.pdf"
|
placeholder="Choose a managed file or pattern"
|
||||||
onClick={() => !disabled && openFileChooser(index)}
|
onClick={() => !disabled && openFileChooser(index)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (!disabled && (event.key === "Enter" || event.key === " ")) {
|
if (!disabled && (event.key === "Enter" || event.key === " ")) {
|
||||||
@@ -275,45 +264,12 @@ function attachmentRuleColumns({ disabled, basePaths, activeChooserRuleIndex: _a
|
|||||||
value: (rule) => getText(rule, "file_filter")
|
value: (rule) => getText(rule, "file_filter")
|
||||||
},
|
},
|
||||||
{ id: "required", header: "Required", width: 175, sortable: true, filterable: true, render: (rule, index) => <ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} />, value: (rule) => getBool(rule, "required", true) ? "required" : "optional" },
|
{ id: "required", header: "Required", width: 175, sortable: true, filterable: true, render: (rule, index) => <ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} />, value: (rule) => getBool(rule, "required", true) ? "required" : "optional" },
|
||||||
{ id: "subdirs", header: "Subdirs", width: 165, sortable: true, filterable: true, render: (rule, index) => <ToggleSwitch label="Subdirs" checked={getBool(rule, "include_subdirs")} disabled={disabled} onChange={(checked) => patchRule(index, { include_subdirs: checked })} />, value: (rule) => getBool(rule, "include_subdirs") ? "subdirs" : "current" },
|
{
|
||||||
{ id: "actions", header: "", width: 120, sticky: "end", render: (_rule, index) => <Button variant="danger" onClick={() => removeRule(index)} disabled={disabled}>Remove</Button> }
|
id: "actions",
|
||||||
|
header: "",
|
||||||
|
width: 145,
|
||||||
|
sticky: "end",
|
||||||
|
render: (_rule, index) => <Button variant="danger" onClick={() => removeRule(index)} disabled={disabled}>Remove</Button>
|
||||||
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function MockFileChooserOverlay({ basePath, onSelect, onClose }: { basePath: string; onSelect: (fileFilter: string) => void; onClose: () => void }) {
|
|
||||||
return createPortal(
|
|
||||||
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="mock-file-chooser-title">
|
|
||||||
<div className="modal-panel attachment-rules-modal">
|
|
||||||
<header className="modal-header">
|
|
||||||
<h2 id="mock-file-chooser-title">Choose file or pattern</h2>
|
|
||||||
<button className="modal-close" onClick={onClose}>×</button>
|
|
||||||
</header>
|
|
||||||
<div className="modal-body attachment-rules-body">
|
|
||||||
<MockFileChooserContent basePath={basePath} onSelect={onSelect} onClose={onClose} />
|
|
||||||
</div>
|
|
||||||
<footer className="modal-footer">
|
|
||||||
<Button onClick={onClose}>Cancel</Button>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MockFileChooserContent({ basePath, onSelect, onClose }: { basePath: string; onSelect: (fileFilter: string) => void; onClose: () => void }) {
|
|
||||||
return (
|
|
||||||
<div className="attachment-file-browser-content" aria-label="Choose file or pattern">
|
|
||||||
<p className="muted small-note">Mock browser below <code>{basePath || "."}</code>. Later this will browse uploaded files and directories.</p>
|
|
||||||
<div className="placeholder-stack attachment-file-browser-list">
|
|
||||||
{mockAttachmentFiles.map((file) => (
|
|
||||||
<Button key={file} onClick={() => onSelect(file)}>
|
|
||||||
<code>{file}</code>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="button-row compact-actions attachment-file-browser-actions">
|
|
||||||
<Button onClick={onClose}>Back</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
572
src/features/campaigns/components/ManagedFileChooser.tsx
Normal file
572
src/features/campaigns/components/ManagedFileChooser.tsx
Normal file
@@ -0,0 +1,572 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { 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 } from "../../files/types";
|
||||||
|
import {
|
||||||
|
buildExplorerEntries,
|
||||||
|
buildFolderTree,
|
||||||
|
formatBytes,
|
||||||
|
formatDate,
|
||||||
|
normalizeFilePath,
|
||||||
|
normalizeFolder,
|
||||||
|
parentFolderPath
|
||||||
|
} 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 childFolders = entries.filter((entry) => entry.kind === "folder");
|
||||||
|
const visibleFiles = entries.filter((entry): entry is Extract<typeof entry, { kind: "file" }> => entry.kind === "file");
|
||||||
|
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 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">
|
||||||
|
<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-list" role="listbox" aria-label={mode === "folder" ? "Folders and files" : "Managed files"}>
|
||||||
|
{currentFolder !== effectiveRoot && (
|
||||||
|
<button type="button" className="managed-file-entry is-folder" onClick={() => openFolder(parentWithinRoot(currentFolder, effectiveRoot))}>
|
||||||
|
<FolderOpen size={18} aria-hidden="true" />
|
||||||
|
<span><strong>..</strong><small>Parent folder</small></span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{childFolders.map((entry) => (
|
||||||
|
<button type="button" key={entry.id} className="managed-file-entry is-folder" onClick={() => openFolder(entry.path)}>
|
||||||
|
<Folder size={18} aria-hidden="true" />
|
||||||
|
<span><strong>{entry.name}</strong><small>{entry.fileCount} file(s), {entry.folderCount} folder(s)</small></span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{visibleFiles.map((entry) => {
|
||||||
|
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" : ""}`}
|
||||||
|
onClick={() => mode === "attachment" ? requestExactFile(entry.file) : undefined}
|
||||||
|
disabled={mode === "folder"}
|
||||||
|
>
|
||||||
|
<File size={18} aria-hidden="true" />
|
||||||
|
<span><strong>{entry.file.filename}</strong><small>{formatBytes(entry.file.size_bytes)} · {formatDate(entry.file.updated_at)}</small></span>
|
||||||
|
{campaignShared && <span className="managed-file-shared-badge"><Link2 size={13} aria-hidden="true" /> linked</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!loading && childFolders.length === 0 && visibleFiles.length === 0 && (
|
||||||
|
<p className="managed-file-empty muted">This folder is empty. Upload files in the top-level Files module.</p>
|
||||||
|
)}
|
||||||
|
</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 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);
|
||||||
|
}
|
||||||
@@ -1,9 +1,29 @@
|
|||||||
|
import type { FileSpace } from "../../../api/files";
|
||||||
import { asArray, asRecord, isRecord } from "./campaignView";
|
import { asArray, asRecord, isRecord } from "./campaignView";
|
||||||
import { getBool, getText } from "./draftEditor";
|
import { getBool, getText } from "./draftEditor";
|
||||||
import { buildTemplatePreviewContext, renderTemplatePreviewText } from "./templatePlaceholders";
|
import { buildTemplatePreviewContext, renderTemplatePreviewText } from "./templatePlaceholders";
|
||||||
|
|
||||||
export type AttachmentRule = Record<string, unknown>;
|
export type AttachmentRule = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type ManagedAttachmentSource = {
|
||||||
|
ownerType: "user" | "group";
|
||||||
|
ownerId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MANAGED_ATTACHMENT_SOURCE_PREFIX = "managed:";
|
||||||
|
|
||||||
|
export function encodeManagedAttachmentSource(space: Pick<FileSpace, "owner_type" | "owner_id">): string {
|
||||||
|
return `${MANAGED_ATTACHMENT_SOURCE_PREFIX}${space.owner_type}:${space.owner_id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseManagedAttachmentSource(value: unknown): ManagedAttachmentSource | null {
|
||||||
|
if (typeof value !== "string" || !value.startsWith(MANAGED_ATTACHMENT_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 };
|
||||||
|
}
|
||||||
|
|
||||||
export type AttachmentBasePath = {
|
export type AttachmentBasePath = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -59,10 +79,10 @@ export function createAttachmentBasePath(name = "New attachment source", path =
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAttachmentRule(baseDir = ""): AttachmentRule {
|
export function createAttachmentRule(baseDir = "", label = ""): AttachmentRule {
|
||||||
return {
|
return {
|
||||||
id: `attachment-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
id: `attachment-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
label: "",
|
label,
|
||||||
base_dir: baseDir,
|
base_dir: baseDir,
|
||||||
file_filter: "",
|
file_filter: "",
|
||||||
required: true,
|
required: true,
|
||||||
@@ -100,6 +120,17 @@ export function getIndividualAttachmentBasePaths(paths: AttachmentBasePath[]): A
|
|||||||
return enabled.length > 0 ? enabled : paths;
|
return enabled.length > 0 ? enabled : paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function nextAttachmentLabel(rules: AttachmentRule[]): string {
|
||||||
|
let highest = 0;
|
||||||
|
for (const rule of rules) {
|
||||||
|
const match = /^Attachment\s+(\d+)$/i.exec(getText(rule, "label").trim());
|
||||||
|
if (match) highest = Math.max(highest, Number(match[1]));
|
||||||
|
}
|
||||||
|
highest = Math.max(highest, rules.length);
|
||||||
|
return `Attachment ${highest + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
|
export function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
return value.filter(isRecord).map((rule) => ({
|
return value.filter(isRecord).map((rule) => ({
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function FolderTree({
|
|||||||
activeSpaceId,
|
activeSpaceId,
|
||||||
spaceId,
|
spaceId,
|
||||||
currentFolder,
|
currentFolder,
|
||||||
dropTargetKey,
|
dropTargetKey = "",
|
||||||
expandedKeys,
|
expandedKeys,
|
||||||
onOpen,
|
onOpen,
|
||||||
onToggle,
|
onToggle,
|
||||||
@@ -87,6 +87,8 @@ export function FolderTree({
|
|||||||
onRequestDragExpand,
|
onRequestDragExpand,
|
||||||
onDragStartFolder,
|
onDragStartFolder,
|
||||||
onDragEndFolder,
|
onDragEndFolder,
|
||||||
|
dragDropEnabled = true,
|
||||||
|
contextMenuEnabled = true,
|
||||||
disabled,
|
disabled,
|
||||||
depth = 1
|
depth = 1
|
||||||
}: {
|
}: {
|
||||||
@@ -94,17 +96,19 @@ export function FolderTree({
|
|||||||
activeSpaceId: string;
|
activeSpaceId: string;
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
currentFolder: string;
|
currentFolder: string;
|
||||||
dropTargetKey: string;
|
dropTargetKey?: string;
|
||||||
expandedKeys: Set<string>;
|
expandedKeys: Set<string>;
|
||||||
onOpen: (spaceId: string, path: string) => void;
|
onOpen: (spaceId: string, path: string) => void;
|
||||||
onToggle: (spaceId: string, path: string) => void;
|
onToggle: (spaceId: string, path: string) => void;
|
||||||
onContextMenu: (event: ReactMouseEvent<HTMLElement>, spaceId: string, path: string) => void;
|
onContextMenu?: (event: ReactMouseEvent<HTMLElement>, spaceId: string, path: string) => void;
|
||||||
onDragOverTarget: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => void;
|
onDragOverTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => void;
|
||||||
onDropOnTarget: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => Promise<void>;
|
onDropOnTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => Promise<void>;
|
||||||
onClearDropState: () => void;
|
onClearDropState?: () => void;
|
||||||
onRequestDragExpand: (spaceId: string, path: string) => void;
|
onRequestDragExpand?: (spaceId: string, path: string) => void;
|
||||||
onDragStartFolder: (spaceId: string, path: string, event: ReactDragEvent<HTMLElement>) => void;
|
onDragStartFolder?: (spaceId: string, path: string, event: ReactDragEvent<HTMLElement>) => void;
|
||||||
onDragEndFolder: () => void;
|
onDragEndFolder?: () => void;
|
||||||
|
dragDropEnabled?: boolean;
|
||||||
|
contextMenuEnabled?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
}) {
|
}) {
|
||||||
@@ -114,7 +118,7 @@ export function FolderTree({
|
|||||||
{nodes.map((node) => {
|
{nodes.map((node) => {
|
||||||
const isActive = activeSpaceId === spaceId && currentFolder === node.path;
|
const isActive = activeSpaceId === spaceId && currentFolder === node.path;
|
||||||
const target = { spaceId, folderPath: node.path };
|
const target = { spaceId, folderPath: node.path };
|
||||||
const isDropTarget = dropTargetKey === `${target.spaceId}:${normalizeFolder(target.folderPath)}`;
|
const isDropTarget = dragDropEnabled && dropTargetKey === `${target.spaceId}:${normalizeFolder(target.folderPath)}`;
|
||||||
const hasChildren = node.children.length > 0;
|
const hasChildren = node.children.length > 0;
|
||||||
const isExpanded = expandedKeys.has(treeNodeKey(spaceId, node.path));
|
const isExpanded = expandedKeys.has(treeNodeKey(spaceId, node.path));
|
||||||
return (
|
return (
|
||||||
@@ -122,16 +126,16 @@ export function FolderTree({
|
|||||||
<div
|
<div
|
||||||
className={`file-tree-node-wrap ${isActive ? "is-active" : ""} ${isDropTarget ? "is-drop-target" : ""}`}
|
className={`file-tree-node-wrap ${isActive ? "is-active" : ""} ${isDropTarget ? "is-drop-target" : ""}`}
|
||||||
style={{ paddingLeft: `${Math.min(depth * 14, 56)}px` }}
|
style={{ paddingLeft: `${Math.min(depth * 14, 56)}px` }}
|
||||||
draggable={!disabled}
|
draggable={dragDropEnabled && !disabled}
|
||||||
onDragStart={(event) => onDragStartFolder(spaceId, node.path, event)}
|
onDragStart={dragDropEnabled && onDragStartFolder ? (event) => onDragStartFolder(spaceId, node.path, event) : undefined}
|
||||||
onDragEnd={onDragEndFolder}
|
onDragEnd={dragDropEnabled ? onDragEndFolder : undefined}
|
||||||
onContextMenu={(event) => onContextMenu(event, spaceId, node.path)}
|
onContextMenu={contextMenuEnabled && onContextMenu ? (event) => onContextMenu(event, spaceId, node.path) : undefined}
|
||||||
onDragOver={(event) => {
|
onDragOver={dragDropEnabled && onDragOverTarget ? (event) => {
|
||||||
onDragOverTarget(event, target);
|
onDragOverTarget(event, target);
|
||||||
if (hasChildren && !isExpanded) onRequestDragExpand(spaceId, node.path);
|
if (hasChildren && !isExpanded) onRequestDragExpand?.(spaceId, node.path);
|
||||||
}}
|
} : undefined}
|
||||||
onDragLeave={onClearDropState}
|
onDragLeave={dragDropEnabled ? onClearDropState : undefined}
|
||||||
onDrop={(event) => void onDropOnTarget(event, target)}
|
onDrop={dragDropEnabled && onDropOnTarget ? (event) => void onDropOnTarget(event, target) : undefined}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -172,6 +176,8 @@ export function FolderTree({
|
|||||||
onRequestDragExpand={onRequestDragExpand}
|
onRequestDragExpand={onRequestDragExpand}
|
||||||
onDragStartFolder={onDragStartFolder}
|
onDragStartFolder={onDragStartFolder}
|
||||||
onDragEndFolder={onDragEndFolder}
|
onDragEndFolder={onDragEndFolder}
|
||||||
|
dragDropEnabled={dragDropEnabled}
|
||||||
|
contextMenuEnabled={contextMenuEnabled}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -818,8 +818,21 @@
|
|||||||
width: min(1120px, 100%);
|
width: min(1120px, 100%);
|
||||||
}
|
}
|
||||||
.attachment-rules-body {
|
.attachment-rules-body {
|
||||||
display: grid;
|
min-height: 0;
|
||||||
gap: 12px;
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.attachment-rules-body > .data-grid-shell:only-child {
|
||||||
|
margin: -22px;
|
||||||
|
width: calc(100% + 44px);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.attachment-rules-body > .data-grid-shell:only-child .data-grid-container {
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.attachment-rules-body > .data-grid-shell:only-child .data-grid-container .data-grid {
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.attachment-rules-empty {
|
.attachment-rules-empty {
|
||||||
border: 1px dashed var(--line-dark);
|
border: 1px dashed var(--line-dark);
|
||||||
@@ -850,11 +863,12 @@
|
|||||||
.attachment-rules-table th:nth-child(3),
|
.attachment-rules-table th:nth-child(3),
|
||||||
.attachment-rules-table td:nth-child(3) { min-width: 230px; }
|
.attachment-rules-table td:nth-child(3) { min-width: 230px; }
|
||||||
.attachment-rules-table th:nth-child(4),
|
.attachment-rules-table th:nth-child(4),
|
||||||
.attachment-rules-table td:nth-child(4),
|
.attachment-rules-table td:nth-child(4) { width: 175px; }
|
||||||
.attachment-rules-table th:nth-child(5),
|
|
||||||
.attachment-rules-table td:nth-child(5) { width: 175px; }
|
|
||||||
.attachment-rules-table th:last-child,
|
.attachment-rules-table th:last-child,
|
||||||
.attachment-rules-table td:last-child { width: 123px; }
|
.attachment-rules-table td:last-child { width: 145px; }
|
||||||
|
.attachment-rules-table .data-grid-body-cell:last-child .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.recipient-editor-table th:nth-child(2),
|
.recipient-editor-table th:nth-child(2),
|
||||||
@@ -1242,3 +1256,340 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Managed file picker shared by attachment sources and rule editors. */
|
||||||
|
.managed-file-chooser-dialog {
|
||||||
|
width: min(1080px, calc(100vw - 40px));
|
||||||
|
max-height: min(820px, calc(100vh - 40px));
|
||||||
|
}
|
||||||
|
.managed-file-chooser-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.managed-file-chooser-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(190px, 240px) minmax(0, 1fr);
|
||||||
|
min-height: 480px;
|
||||||
|
max-height: calc(100vh - 240px);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
.managed-file-chooser-spaces {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 16px;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.managed-file-chooser-spaces h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.managed-file-space-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
.managed-file-space-button,
|
||||||
|
.managed-file-entry {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.managed-file-space-button {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 20px minmax(0, 1fr);
|
||||||
|
gap: 9px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 9px;
|
||||||
|
}
|
||||||
|
.managed-file-space-button span,
|
||||||
|
.managed-file-entry > span:not(.managed-file-shared-badge) {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.managed-file-space-button small,
|
||||||
|
.managed-file-entry small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.managed-file-space-button:hover:not(:disabled),
|
||||||
|
.managed-file-space-button.is-selected {
|
||||||
|
border-color: var(--line-dark);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
.managed-file-space-button.is-selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: inset 3px 0 0 var(--accent);
|
||||||
|
}
|
||||||
|
.managed-file-space-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: .45;
|
||||||
|
}
|
||||||
|
.managed-file-chooser-browser {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.managed-file-chooser-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.managed-file-breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.managed-file-breadcrumb > button,
|
||||||
|
.managed-file-breadcrumb span button {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 5px;
|
||||||
|
align-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 5px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.managed-file-breadcrumb button:hover:not(:disabled) {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
.managed-file-choice-tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--line-dark);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.managed-file-choice-tabs button {
|
||||||
|
border: 0;
|
||||||
|
border-right: 1px solid var(--line-dark);
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 7px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.managed-file-choice-tabs button:last-child { border-right: 0; }
|
||||||
|
.managed-file-choice-tabs button.is-active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.managed-pattern-editor {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
.managed-pattern-editor label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.managed-file-entry-list {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.managed-file-entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 22px minmax(0, 1fr) auto;
|
||||||
|
gap: 9px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.managed-file-entry:hover:not(:disabled) {
|
||||||
|
border-color: var(--line);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
.managed-file-entry.is-selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
.managed-file-entry:disabled {
|
||||||
|
color: var(--ink);
|
||||||
|
opacity: 1;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.managed-file-entry.is-folder:not(:disabled) { cursor: pointer; }
|
||||||
|
.managed-file-shared-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--success-line, var(--line));
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 7px;
|
||||||
|
color: var(--success, var(--muted));
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.managed-file-empty {
|
||||||
|
padding: 28px 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.managed-file-chooser-note {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.managed-file-chooser-dialog {
|
||||||
|
width: calc(100vw - 20px);
|
||||||
|
}
|
||||||
|
.managed-file-chooser-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
max-height: calc(100vh - 220px);
|
||||||
|
}
|
||||||
|
.managed-file-chooser-spaces {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
max-height: 160px;
|
||||||
|
}
|
||||||
|
.managed-file-chooser-toolbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.attachment-source-path-cell {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.attachment-source-path-cell .small-note {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.warning-text {
|
||||||
|
color: var(--warning, #8a5b00);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Managed attachment chooser refinements. */
|
||||||
|
.split-field-action {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.split-field-action > input,
|
||||||
|
.split-field-action > select,
|
||||||
|
.split-field-action > textarea {
|
||||||
|
border-top-right-radius: 0 !important;
|
||||||
|
border-bottom-right-radius: 0 !important;
|
||||||
|
}
|
||||||
|
.split-field-action > button,
|
||||||
|
.split-field-action > .button,
|
||||||
|
.split-field-action > .btn {
|
||||||
|
align-self: stretch;
|
||||||
|
margin-left: -1px;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.managed-file-chooser-dialog {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
height: min(820px, calc(100vh - 40px));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.managed-file-chooser-dialog > .dialog-header {
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
.managed-file-chooser-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.managed-file-chooser-footer {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: 0 -8px 24px rgba(15, 23, 42, .06);
|
||||||
|
}
|
||||||
|
.managed-file-chooser-layout {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
.managed-file-chooser-spaces.file-tree-panel {
|
||||||
|
display: flex;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.managed-file-chooser-spaces .file-tree-list {
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
.managed-pattern-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.managed-pattern-results {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.managed-pattern-results .file-list-table {
|
||||||
|
min-width: 680px;
|
||||||
|
}
|
||||||
|
.managed-pattern-result-row {
|
||||||
|
width: 100%;
|
||||||
|
border-top: 0;
|
||||||
|
border-right: 0;
|
||||||
|
border-left: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.managed-pattern-result-row .file-list-name > span {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.managed-pattern-result-row .file-list-name strong {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.managed-pattern-result-row .file-list-name small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.managed-file-chooser-dialog {
|
||||||
|
height: calc(100vh - 20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -860,6 +860,22 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.data-grid-header-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-grid-header-label > span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.data-grid-header-cell.is-sortable .data-grid-header-button {
|
.data-grid-header-cell.is-sortable .data-grid-header-button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,12 +100,12 @@
|
|||||||
.account-menu-header strong { display: block; color: var(--text-strong); }
|
.account-menu-header strong { display: block; color: var(--text-strong); }
|
||||||
.account-menu-header span { color: var(--muted); font-size: 13px; }
|
.account-menu-header span { color: var(--muted); font-size: 13px; }
|
||||||
.overlay-backdrop { position: fixed; inset: 0; background: rgba(37,40,42,.38); display: grid; place-items: center; z-index: 9000; padding: 24px; }
|
.overlay-backdrop { position: fixed; inset: 0; background: rgba(37,40,42,.38); display: grid; place-items: center; z-index: 9000; padding: 24px; }
|
||||||
.modal-panel { width: min(560px, 100%); max-height: calc(100vh - 48px); overflow: auto; background: #fff; border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 24px 80px rgba(0,0,0,.26); }
|
.modal-panel { width: min(560px, 100%); max-height: calc(100vh - 48px); overflow: hidden; display: flex; flex-direction: column; background: #fff; border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 24px 80px rgba(0,0,0,.26); }
|
||||||
.modal-header { min-height: 58px; display: flex; align-items: center; padding: 0 22px; border-bottom: 1px solid var(--line); }
|
.modal-header { min-height: 58px; display: flex; align-items: center; flex: 0 0 auto; padding: 0 22px; border-bottom: 1px solid var(--line); }
|
||||||
.modal-header h2 { margin: 0; font-size: 18px; color: var(--text-strong); }
|
.modal-header h2 { margin: 0; font-size: 18px; color: var(--text-strong); }
|
||||||
.modal-close { margin-left: auto; border: 0; background: transparent; font-size: 22px; line-height: 1; cursor: pointer; color: var(--muted); }
|
.modal-close { margin-left: auto; border: 0; background: transparent; font-size: 22px; line-height: 1; cursor: pointer; color: var(--muted); }
|
||||||
.modal-body { padding: 22px; }
|
.modal-body { min-height: 0; overflow: auto; padding: 22px; }
|
||||||
.modal-footer { padding: 16px 22px; border-top: 1px solid var(--line); display: flex; justify-content: flex-end; gap: 10px; background: var(--panel-soft); }
|
.modal-footer { flex: 0 0 auto; padding: 16px 22px; border-top: 1px solid var(--line); display: flex; justify-content: flex-end; gap: 10px; background: var(--panel-soft); }
|
||||||
.login-hint { background: #f6f5f3; border: 1px solid var(--line); padding: 10px 12px; border-radius: 4px; color: var(--muted); font-size: 13px; }
|
.login-hint { background: #f6f5f3; border: 1px solid var(--line); padding: 10px 12px; border-radius: 4px; color: var(--muted); font-size: 13px; }
|
||||||
.help-panel-section { margin-bottom: 16px; }
|
.help-panel-section { margin-bottom: 16px; }
|
||||||
.help-panel-section h3 { margin: 0 0 7px; color: var(--text-strong); }
|
.help-panel-section h3 { margin: 0 0 7px; color: var(--text-strong); }
|
||||||
|
|||||||
Reference in New Issue
Block a user