FileChooser in Attachments

This commit is contained in:
2026-06-13 04:05:43 +02:00
parent 8d2fe5b77b
commit 76ff0f9d5f
10 changed files with 1145 additions and 195 deletions

View File

@@ -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 ? (
<span>{column.header}</span> <button type="button" className="data-grid-header-button" onClick={() => toggleSort(column)}>
{column.sortable && <SortIcon direction={sorted} />} <span>{column.header}</span>
</button> <SortIcon direction={sorted} />
</button>
) : (
<div className="data-grid-header-label"><span>{column.header}</span></div>
)}
{column.filterable && ( {column.filterable && (
<button <button
type="button" type="button"

View File

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

View File

@@ -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)}
/> />

View File

@@ -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 rules={rules}
basePath={activeBasePath} settings={settings}
onSelect={selectFileFilter} campaignId={campaignId}
onClose={() => setFileChooser(null)} disabled={disabled}
/> emptyText={emptyText}
) : ( basePaths={basePaths}
<> activeChooserRuleIndex={null}
<p className="muted small-note">Use direct files for fixed attachments and rules/patterns for files resolved during build.</p> onChange={onChange}
<AttachmentRulesTable />
rules={rules}
disabled={disabled}
emptyText={emptyText}
basePaths={basePaths}
activeChooserRuleIndex={null}
onOpenFileChooser={openFileChooser}
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,40 +157,47 @@ 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 ? ( <DataGrid
<p className="muted attachment-rules-empty">{emptyText}</p> id={id}
) : ( rows={rules}
<DataGrid columns={attachmentRuleColumns({ disabled, basePaths, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, openFileChooser, removeRule })}
id={id} getRowKey={(rule, index) => String(rule.id ?? index)}
rows={rules} emptyText={emptyText}
columns={attachmentRuleColumns({ disabled, basePaths, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, openFileChooser, removeRule })} className="attachment-rules-table-wrap attachment-rules-table"
getRowKey={(rule, index) => String(rule.id ?? index)} rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined}
emptyText={emptyText} />
className="attachment-rules-table-wrap attachment-rules-table"
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>
);
}

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

View File

@@ -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) => ({

View File

@@ -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}
/> />

View File

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

View File

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

View File

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