UI polishes, file chooser

This commit is contained in:
2026-06-10 14:09:50 +02:00
parent 1f34435893
commit 4544a89443
13 changed files with 357 additions and 115 deletions

View File

@@ -158,7 +158,21 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
<td><input value={basePath.name} disabled={locked} placeholder="Campaign files" onChange={(event) => patchBasePath(index, { name: event.target.value })} /></td>
<td>
<div className="field-with-action">
<input value={basePath.path} disabled={locked} readOnly placeholder="attachments" />
<input
className="chooser-display-input"
value={basePath.path}
disabled={locked}
readOnly
tabIndex={-1}
placeholder="attachments"
onClick={() => !locked && setPathChooser({ index })}
onKeyDown={(event) => {
if (!locked && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
setPathChooser({ index });
}
}}
/>
<Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose</Button>
</div>
</td>

View File

@@ -1,11 +1,28 @@
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import PageTitle from "../../components/PageTitle";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { formatDateTime } from "./utils/campaignView";
export default function CampaignAuditPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion;
export default function CampaignAuditPage() {
return (
<div className="content-pad workspace-data-page">
<div className="page-heading">
<h1>Audit log</h1>
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Audit log</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
<Card title="Recent audit events">
<p className="muted">Campaign-specific audit API integration will be added in the audit section pass.</p>
</Card>

View File

@@ -3,12 +3,13 @@ import Card from "../../components/Card";
import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, getCampaignJson } from "./utils/campaignView";
import { asRecord, formatDateTime, getCampaignJson } from "./utils/campaignView";
import { downloadJson, safeFileStem } from "./utils/draftEditor";
export default function CampaignJsonView({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
const campaignJson = getCampaignJson(data.currentVersion);
const version = data.currentVersion;
const campaignJson = getCampaignJson(version);
const campaign = asRecord(campaignJson.campaign);
const filename = `${safeFileStem(String(campaign.id || data.campaign?.external_id || campaignId))}.json`;
@@ -17,15 +18,16 @@ export default function CampaignJsonView({ settings, campaignId }: { settings: A
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>JSON</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!data.currentVersion}>Download JSON</Button>
<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!version}>Download JSON</Button>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
<Card>
{!loading || data.currentVersion ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}
{!loading || version ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}
</Card>
</div>
);

View File

@@ -4,7 +4,7 @@ import type { ApiSettings } from "../../types";
import Card from "../../components/Card";
import Button from "../../components/Button";
import StatusBadge from "../../components/StatusBadge";
import LoadingIndicator from "../../components/LoadingIndicator";
import PageTitle from "../../components/PageTitle";
import { createNewCampaign, listCampaigns } from "../../api/campaigns";
import type { CampaignListItem } from "../../types";
@@ -58,21 +58,20 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
{error && <div className="alert danger">{error}</div>}
<Card
title={<span className="card-heading-with-loader">All campaigns {loading && <LoadingIndicator label="Loading campaigns" />}</span>}
actions={
<div className="campaign-card-actions">
{lastLoadedAt && <span className="last-loaded">Last loaded: {lastLoadedAt}</span>}
<div className="button-row compact-actions">
<Button onClick={load} disabled={!hasAuth || loading}>Reload</Button>
<Button variant="primary" onClick={create} disabled={!hasAuth || creating}>
{creating ? "Creating…" : "New campaign"}
</Button>
</div>
</div>
}
>
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>All campaigns</PageTitle>
<p className="mono-small">{lastLoadedAt ? `Last loaded: ${lastLoadedAt}` : "Not loaded yet"}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={load} disabled={!hasAuth || loading}>Reload</Button>
<Button variant="primary" onClick={create} disabled={!hasAuth || creating}>
{creating ? "Creating…" : "New campaign"}
</Button>
</div>
</div>
<Card>
{!loading && campaigns.length === 0 && (
<div className="empty-state">
<h2>No campaigns yet</h2>

View File

@@ -6,7 +6,8 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { formatDateTime } from "./utils/campaignView";
export default function CampaignReportPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeCurrentVersion: false, includeSummary: true });
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const version = data.currentVersion;
const cards = data.summary?.cards;
return (
@@ -14,6 +15,7 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Report</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at ?? data.summary?.generated_at)}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>

View File

@@ -76,7 +76,7 @@ function CampaignWorkspaceInner({ settings }: { settings: ApiSettings }) {
<Route path="send" element={<SendDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="reports" element={<Navigate to="../report" replace />} />
<Route path="audit" element={<CampaignAuditPage />} />
<Route path="audit" element={<CampaignAuditPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="json" element={<CampaignJsonView settings={settings} campaignId={campaignId || ""} />} />
<Route path="wizard/create" element={<CreateWizard settings={settings} campaignId={campaignId || ""} />} />
<Route path="wizard/review" element={<ReviewWizard />} />

View File

@@ -12,7 +12,7 @@ import { asArray, asRecord, formatDateTime, isAuditLockedVersion, isRecord, vers
import { ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
import FieldValueInput from "./components/FieldValueInput";
import AttachmentRulesOverlay, { type AttachmentRule } from "./components/AttachmentRulesOverlay";
import AttachmentRulesOverlay, { type AttachmentBasePath, type AttachmentRule } from "./components/AttachmentRulesOverlay";
import {
addressesFromValue,
collectCampaignAddressSuggestions,
@@ -46,6 +46,12 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
const inlineEntries = asArray(entries.inline).map(asRecord);
const source = asRecord(entries.source);
const fieldDefinitions = useMemo(() => getDraftFields(draft), [draft]);
const attachmentSection = asRecord(draft?.attachments);
const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [draft?.attachments]);
const individualAttachmentBasePaths = useMemo(() => {
const enabled = attachmentBasePaths.filter((basePath) => basePath.allow_individual);
return enabled.length > 0 ? enabled : attachmentBasePaths;
}, [attachmentBasePaths]);
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(draft), [draft]);
const globalRecipientValues: Record<string, MailboxAddress[]> = {
to: addressesFromValue(recipientsSection.to),
@@ -292,6 +298,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
title={`Attachments for recipient ${index + 1}`}
rules={attachments}
disabled={locked}
basePaths={individualAttachmentBasePaths}
onChange={(rules) => updateEntryAttachments(index, rules)}
/>
</td>
@@ -347,6 +354,25 @@ function fallbackRecipientAddress(entry: Record<string, unknown>): MailboxAddres
return direct?.email ? [direct] : [];
}
function normalizeAttachmentBasePaths(value: unknown, attachments: Record<string, unknown>): AttachmentBasePath[] {
if (Array.isArray(value) && value.length > 0) {
return value.filter(isRecord).map((basePath, index) => ({
id: getText(basePath, "id", `base-path-${index + 1}`),
name: getText(basePath, "name", `Base path ${index + 1}`),
source: getText(basePath, "source"),
path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."),
allow_individual: getBool(basePath, "allow_individual")
}));
}
return [{
id: "base-path-campaign",
name: "Campaign files",
path: getText(attachments, "base_path", "."),
allow_individual: getBool(attachments, "allow_individual", true)
}];
}
function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
if (!Array.isArray(value)) return [];
return value.filter(isRecord).map((rule) => ({ ...rule }));

View File

@@ -17,6 +17,7 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Review</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>

View File

@@ -5,12 +5,13 @@ import PageTitle from "../../components/PageTitle";
import Card from "../../components/Card";
import MetricCard from "../../components/MetricCard";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, getDeliverySection, getNestedString } from "./utils/campaignView";
import { asRecord, formatDateTime, getDeliverySection, getNestedString } from "./utils/campaignView";
export default function SendDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const version = data.currentVersion;
const cards = data.summary?.cards;
const delivery = getDeliverySection(data.currentVersion);
const delivery = getDeliverySection(version);
const rateLimit = asRecord(delivery.rate_limit);
const imapAppend = asRecord(delivery.imap_append_sent);
const retry = asRecord(delivery.retry);
@@ -20,6 +21,7 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Send</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>

View File

@@ -29,6 +29,8 @@ type AttachmentRulesTableProps = {
emptyText?: string;
basePaths?: AttachmentBasePath[];
showAddButton?: boolean;
activeChooserRuleIndex?: number | null;
onOpenFileChooser?: (ruleIndex: number) => void;
onChange: (rules: AttachmentRule[]) => void;
};
@@ -47,28 +49,70 @@ export default function AttachmentRulesOverlay({
onChange
}: AttachmentRulesOverlayProps) {
const [open, setOpen] = useState(false);
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
const summary = useMemo(() => summarizeAttachmentRules(rules), [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() {
setFileChooser(null);
setOpen(false);
}
const activeRule = fileChooser ? rules[fileChooser.ruleIndex] : undefined;
const activeBasePath = fileChooser
? getText(activeRule ?? {}, "base_dir", fileChooser.basePath || basePaths[0]?.path || "")
: "";
const dialog = open ? createPortal(
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="attachment-rules-title">
<div className="modal-panel attachment-rules-modal">
<header className="modal-header">
<h2 id="attachment-rules-title">{title}</h2>
<button className="modal-close" onClick={() => setOpen(false)}>×</button>
<h2 id="attachment-rules-title">{fileChooser ? "Choose file or pattern" : title}</h2>
<button className="modal-close" onClick={closeOverlay}>×</button>
</header>
<div className="modal-body attachment-rules-body">
<p className="muted small-note">Use direct files for fixed attachments and rules/patterns for files resolved during build.</p>
<AttachmentRulesTable
rules={rules}
disabled={disabled}
emptyText={emptyText}
basePaths={basePaths}
onChange={onChange}
/>
{fileChooser ? (
<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}
disabled={disabled}
emptyText={emptyText}
basePaths={basePaths}
activeChooserRuleIndex={null}
onOpenFileChooser={openFileChooser}
onChange={onChange}
/>
</>
)}
</div>
<footer className="modal-footer">
<Button variant="primary" onClick={() => setOpen(false)}>Close</Button>
{fileChooser ? (
<Button onClick={() => setFileChooser(null)}>Back to rules</Button>
) : (
<Button variant="primary" onClick={closeOverlay}>Close</Button>
)}
</footer>
</div>
</div>,
@@ -91,6 +135,8 @@ export function AttachmentRulesTable({
emptyText = "No attachment files or matching rules configured yet.",
basePaths = [],
showAddButton = true,
activeChooserRuleIndex = null,
onOpenFileChooser,
onChange
}: AttachmentRulesTableProps) {
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
@@ -115,90 +161,112 @@ export function AttachmentRulesTable({
}
function removeRule(index: number) {
setFileChooser(null);
onChange(rules.filter((_, currentIndex) => currentIndex !== index));
}
function openFileChooser(ruleIndex: number) {
if (onOpenFileChooser) {
onOpenFileChooser(ruleIndex);
return;
}
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);
}
const activeRule = fileChooser ? rules[fileChooser.ruleIndex] : undefined;
const activeBasePath = fileChooser
? getText(activeRule ?? {}, "base_dir", fileChooser.basePath || basePaths[0]?.path || "")
: "";
return (
<>
{rules.length === 0 ? (
<p className="muted attachment-rules-empty">{emptyText}</p>
) : (
<div className="app-table-wrap attachment-rules-table-wrap">
<table className="app-table attachment-rules-table">
<thead>
<tr>
<th>Label</th>
<th>Base path</th>
<th>File / pattern</th>
<th>Required</th>
<th>Subdirs</th>
<th></th>
</tr>
</thead>
<tbody>
{rules.map((rule, index) => {
const currentBasePath = getText(rule, "base_dir", basePaths[0]?.path ?? "");
return (
<tr key={String(rule.id ?? index)}>
<td><input value={getText(rule, "label")} disabled={disabled} placeholder="Attachment label" onChange={(event) => patchRule(index, { label: event.target.value })} /></td>
<td>
{basePaths.length > 0 ? (
<select value={currentBasePath} disabled={disabled} onChange={(event) => patchRule(index, { base_dir: event.target.value })}>
{basePaths.map((basePath) => <option key={basePath.id} value={basePath.path}>{basePath.name || basePath.path}</option>)}
</select>
) : (
<input value={currentBasePath} disabled={disabled} readOnly placeholder="optional/folder" />
)}
</td>
<td>
<div className="field-with-action">
<input value={getText(rule, "file_filter")} disabled={disabled} readOnly placeholder="file.pdf or {{local:id}}.pdf" />
<Button onClick={() => openFileChooser(index)} disabled={disabled}>Choose</Button>
</div>
</td>
<td><ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} /></td>
<td><ToggleSwitch label="Subdirs" checked={getBool(rule, "include_subdirs")} disabled={disabled} onChange={(checked) => patchRule(index, { include_subdirs: checked })} /></td>
<td className="table-action-cell"><Button variant="danger" onClick={() => removeRule(index)} disabled={disabled}>Remove</Button></td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{showAddButton && (
<div className="button-row compact-actions">
<Button onClick={addRule} disabled={disabled}>Add file</Button>
</div>
)}
<div className="attachment-rules-editor">
<div className="attachment-rules-main">
{rules.length === 0 ? (
<p className="muted attachment-rules-empty">{emptyText}</p>
) : (
<div className="app-table-wrap attachment-rules-table-wrap">
<table className="app-table attachment-rules-table">
<thead>
<tr>
<th>Label</th>
<th>Base path</th>
<th>File / pattern</th>
<th>Required</th>
<th>Subdirs</th>
<th></th>
</tr>
</thead>
<tbody>
{rules.map((rule, index) => {
const currentBasePath = getText(rule, "base_dir", basePaths[0]?.path ?? "");
const isChoosingFile = (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index;
return (
<tr key={String(rule.id ?? index)} className={isChoosingFile ? "is-choosing-file" : undefined}>
<td><input value={getText(rule, "label")} disabled={disabled} placeholder="Attachment label" onChange={(event) => patchRule(index, { label: event.target.value })} /></td>
<td>
{basePaths.length > 0 ? (
<select value={currentBasePath} disabled={disabled} onChange={(event) => patchRule(index, { base_dir: event.target.value })}>
{basePaths.map((basePath) => <option key={basePath.id} value={basePath.path}>{basePath.name || basePath.path}</option>)}
</select>
) : (
<input value={currentBasePath} disabled={disabled} readOnly placeholder="optional/folder" />
)}
</td>
<td>
<div className="field-with-action">
<input
className="chooser-display-input"
value={getText(rule, "file_filter")}
disabled={disabled}
readOnly
tabIndex={-1}
placeholder="file.pdf or {{local:id}}.pdf"
onClick={() => !disabled && openFileChooser(index)}
onKeyDown={(event) => {
if (!disabled && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
openFileChooser(index);
}
}}
/>
<Button onClick={() => openFileChooser(index)} disabled={disabled}>Choose</Button>
</div>
</td>
<td><ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} /></td>
<td><ToggleSwitch label="Subdirs" checked={getBool(rule, "include_subdirs")} disabled={disabled} onChange={(checked) => patchRule(index, { include_subdirs: checked })} /></td>
<td className="table-action-cell"><Button variant="danger" onClick={() => removeRule(index)} disabled={disabled}>Remove</Button></td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{showAddButton && (
<div className="button-row compact-actions attachment-rules-footer-actions">
<Button onClick={addRule} disabled={disabled}>Add file</Button>
</div>
)}
</div>
{fileChooser && (
<MockFileChooserOverlay
basePath={fileChooser.basePath}
onSelect={(fileFilter) => {
patchRule(fileChooser.ruleIndex, { file_filter: fileFilter });
setFileChooser(null);
}}
basePath={activeBasePath}
onSelect={selectFileFilter}
onClose={() => setFileChooser(null)}
/>
)}
</>
</div>
);
}
function MockFileChooserOverlay({ basePath, onSelect, onClose }: { basePath: string; onSelect: (fileFilter: string) => void; onClose: () => void }) {
const files = [
"welcome.pdf",
"terms-and-conditions.pdf",
"invoice_{{local:invoice_number}}.pdf",
"{{local:recipient_id}}/certificate.pdf",
"attachments/{{local:email}}/*.pdf"
];
return createPortal(
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="mock-file-chooser-title">
<div className="modal-panel attachment-rules-modal">
@@ -207,14 +275,7 @@ function MockFileChooserOverlay({ basePath, onSelect, onClose }: { basePath: str
<button className="modal-close" onClick={onClose}>×</button>
</header>
<div className="modal-body attachment-rules-body">
<p className="muted small-note">Mock chooser for now. Later this will browse uploaded files below <code>{basePath || "."}</code>.</p>
<div className="placeholder-stack">
{files.map((file) => (
<Button key={file} onClick={() => onSelect(file)}>
<code>{file}</code>
</Button>
))}
</div>
<MockFileChooserContent basePath={basePath} onSelect={onSelect} onClose={onClose} />
</div>
<footer className="modal-footer">
<Button onClick={onClose}>Cancel</Button>
@@ -225,6 +286,32 @@ function MockFileChooserOverlay({ basePath, onSelect, onClose }: { basePath: str
);
}
function MockFileChooserContent({ basePath, onSelect, onClose }: { basePath: string; onSelect: (fileFilter: string) => void; onClose: () => void }) {
const files = [
"welcome.pdf",
"terms-and-conditions.pdf",
"invoice_{{local:invoice_number}}.pdf",
"{{local:recipient_id}}/certificate.pdf",
"attachments/{{local:email}}/*.pdf"
];
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">
{files.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>
);
}
export function summarizeAttachmentRules(rules: AttachmentRule[]): { direct: number; rules: number } {
return rules.reduce<{ direct: number; rules: number }>((summary, rule) => {
if (isDirectAttachmentRule(rule)) {

View File

@@ -829,3 +829,92 @@
.recipient-editor-table td:nth-child(2) { min-width: 300px; }
.recipient-address-stack { min-width: 260px; }
}
/* Attachment file chooser panel embedded in the shared rules editor. */
.attachment-rules-editor {
display: grid;
gap: 12px;
min-width: 0;
}
.attachment-rules-editor.has-file-browser {
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
align-items: start;
}
.attachment-rules-main {
min-width: 0;
}
.attachment-rules-table tr.is-choosing-file td {
background: var(--panel-soft);
}
.attachment-rules-footer-actions {
margin-top: 12px;
}
.attachment-file-browser-panel {
display: grid;
gap: 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel-soft);
padding: 14px;
min-width: 0;
}
.attachment-file-browser-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: start;
}
.attachment-file-browser-header h3 {
margin: 0 0 4px;
font-size: 15px;
}
.attachment-file-browser-list {
display: grid;
gap: 8px;
}
.attachment-file-browser-list .btn {
justify-content: flex-start;
text-align: left;
white-space: normal;
}
.attachment-file-browser-actions {
justify-content: flex-end;
}
@media (max-width: 1100px) {
.attachment-rules-editor.has-file-browser {
grid-template-columns: 1fr;
}
}
/* Chooser-backed readonly fields: legible, clickable display values without a text caret. */
.chooser-display-input,
.field-with-action input.chooser-display-input {
background: var(--panel-soft);
border-color: var(--line-dark);
color: var(--ink);
cursor: pointer;
caret-color: transparent;
user-select: none;
}
.chooser-display-input:hover,
.field-with-action input.chooser-display-input:hover {
background: var(--panel);
border-color: var(--accent);
}
.chooser-display-input:focus,
.field-with-action input.chooser-display-input:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(239, 107, 58, .16);
}
.chooser-display-input:disabled,
.field-with-action input.chooser-display-input:disabled {
background: var(--panel-soft);
border-color: var(--line);
color: var(--muted);
cursor: not-allowed;
opacity: 1;
}