diff --git a/.gitignore b/.gitignore index 5588837..57968ce 100644 --- a/.gitignore +++ b/.gitignore @@ -420,4 +420,7 @@ bin-release/ # should NOT be excluded as they contain compiler settings and other important # information for Eclipse / Flash Builder. -.fuse_* \ No newline at end of file +.fuse_* + +multisealmail-*.zip +multi-seal-mail-webui*.tar.gz \ No newline at end of file diff --git a/multi-seal-mail-webui.tar.gz b/multi-seal-mail-webui.tar.gz deleted file mode 100644 index d246448..0000000 Binary files a/multi-seal-mail-webui.tar.gz and /dev/null differ diff --git a/src/features/campaigns/AttachmentsDataPage.tsx b/src/features/campaigns/AttachmentsDataPage.tsx index 3d35335..acf8c7c 100644 --- a/src/features/campaigns/AttachmentsDataPage.tsx +++ b/src/features/campaigns/AttachmentsDataPage.tsx @@ -158,7 +158,21 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings patchBasePath(index, { name: event.target.value })} />
- + !locked && setPathChooser({ index })} + onKeyDown={(event) => { + if (!locked && (event.key === "Enter" || event.key === " ")) { + event.preventDefault(); + setPathChooser({ index }); + } + }} + />
diff --git a/src/features/campaigns/CampaignAuditPage.tsx b/src/features/campaigns/CampaignAuditPage.tsx index 28a85be..974b277 100644 --- a/src/features/campaigns/CampaignAuditPage.tsx +++ b/src/features/campaigns/CampaignAuditPage.tsx @@ -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 (
-
-

Audit log

+
+
+ Audit log +

Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}

+
+
+ +
+ + {error &&
{error}
} +

Campaign-specific audit API integration will be added in the audit section pass.

diff --git a/src/features/campaigns/CampaignJsonView.tsx b/src/features/campaigns/CampaignJsonView.tsx index 80a1c24..513edc6 100644 --- a/src/features/campaigns/CampaignJsonView.tsx +++ b/src/features/campaigns/CampaignJsonView.tsx @@ -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
JSON +

Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}

- +
{error &&
{error}
} - {!loading || data.currentVersion ?
{JSON.stringify(campaignJson, null, 2)}
:
{"{}"}
} + {!loading || version ?
{JSON.stringify(campaignJson, null, 2)}
:
{"{}"}
}
); diff --git a/src/features/campaigns/CampaignListPage.tsx b/src/features/campaigns/CampaignListPage.tsx index f852d90..f5a469d 100644 --- a/src/features/campaigns/CampaignListPage.tsx +++ b/src/features/campaigns/CampaignListPage.tsx @@ -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 &&
{error}
} - All campaigns {loading && }} - actions={ -
- {lastLoadedAt && Last loaded: {lastLoadedAt}} -
- - -
-
- } - > - +
+
+ All campaigns +

{lastLoadedAt ? `Last loaded: ${lastLoadedAt}` : "Not loaded yet"}

+
+
+ + +
+
+ + {!loading && campaigns.length === 0 && (

No campaigns yet

diff --git a/src/features/campaigns/CampaignReportPage.tsx b/src/features/campaigns/CampaignReportPage.tsx index 5eee7e1..5666e41 100644 --- a/src/features/campaigns/CampaignReportPage.tsx +++ b/src/features/campaigns/CampaignReportPage.tsx @@ -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:
Report +

Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at ?? data.summary?.generated_at)}

diff --git a/src/features/campaigns/CampaignWorkspace.tsx b/src/features/campaigns/CampaignWorkspace.tsx index 1d1f1b0..cbc864e 100644 --- a/src/features/campaigns/CampaignWorkspace.tsx +++ b/src/features/campaigns/CampaignWorkspace.tsx @@ -76,7 +76,7 @@ function CampaignWorkspaceInner({ settings }: { settings: ApiSettings }) { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/features/campaigns/RecipientDataPage.tsx b/src/features/campaigns/RecipientDataPage.tsx index a974760..08526f8 100644 --- a/src/features/campaigns/RecipientDataPage.tsx +++ b/src/features/campaigns/RecipientDataPage.tsx @@ -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 = { 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)} /> @@ -347,6 +354,25 @@ function fallbackRecipientAddress(entry: Record): MailboxAddres return direct?.email ? [direct] : []; } +function normalizeAttachmentBasePaths(value: unknown, attachments: Record): 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 })); diff --git a/src/features/campaigns/ReviewDataPage.tsx b/src/features/campaigns/ReviewDataPage.tsx index e7e3d1b..05f2a8e 100644 --- a/src/features/campaigns/ReviewDataPage.tsx +++ b/src/features/campaigns/ReviewDataPage.tsx @@ -17,6 +17,7 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
Review +

Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}

diff --git a/src/features/campaigns/SendDataPage.tsx b/src/features/campaigns/SendDataPage.tsx index e40c580..e3816dd 100644 --- a/src/features/campaigns/SendDataPage.tsx +++ b/src/features/campaigns/SendDataPage.tsx @@ -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
Send +

Version {version ? `#${version.version_number}` : "—"} · Loaded {formatDateTime(version?.updated_at)}

diff --git a/src/features/campaigns/components/AttachmentRulesOverlay.tsx b/src/features/campaigns/components/AttachmentRulesOverlay.tsx index 25df9a9..f8e01b7 100644 --- a/src/features/campaigns/components/AttachmentRulesOverlay.tsx +++ b/src/features/campaigns/components/AttachmentRulesOverlay.tsx @@ -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(null); const summary = useMemo(() => summarizeAttachmentRules(rules), [rules]); const label = buttonLabel ?? `direct: ${summary.direct} / rules: ${summary.rules}`; + function patchRule(index: number, patch: Partial) { + 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(
-

{title}

- +

{fileChooser ? "Choose file or pattern" : title}

+
-

Use direct files for fixed attachments and rules/patterns for files resolved during build.

- + {fileChooser ? ( + setFileChooser(null)} + /> + ) : ( + <> +

Use direct files for fixed attachments and rules/patterns for files resolved during build.

+ + + )}
- + {fileChooser ? ( + + ) : ( + + )}
, @@ -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(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 ? ( -

{emptyText}

- ) : ( -
- - - - - - - - - - - - - {rules.map((rule, index) => { - const currentBasePath = getText(rule, "base_dir", basePaths[0]?.path ?? ""); - return ( - - - - - - - - - ); - })} - -
LabelBase pathFile / patternRequiredSubdirs
patchRule(index, { label: event.target.value })} /> - {basePaths.length > 0 ? ( - - ) : ( - - )} - -
- - -
-
patchRule(index, { required: checked })} /> patchRule(index, { include_subdirs: checked })} />
-
- )} - {showAddButton && ( -
- -
- )} +
+
+ {rules.length === 0 ? ( +

{emptyText}

+ ) : ( +
+ + + + + + + + + + + + + {rules.map((rule, index) => { + const currentBasePath = getText(rule, "base_dir", basePaths[0]?.path ?? ""); + const isChoosingFile = (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index; + return ( + + + + + + + + + ); + })} + +
LabelBase pathFile / patternRequiredSubdirs
patchRule(index, { label: event.target.value })} /> + {basePaths.length > 0 ? ( + + ) : ( + + )} + +
+ !disabled && openFileChooser(index)} + onKeyDown={(event) => { + if (!disabled && (event.key === "Enter" || event.key === " ")) { + event.preventDefault(); + openFileChooser(index); + } + }} + /> + +
+
patchRule(index, { required: checked })} /> patchRule(index, { include_subdirs: checked })} />
+
+ )} + {showAddButton && ( +
+ +
+ )} +
{fileChooser && ( { - patchRule(fileChooser.ruleIndex, { file_filter: fileFilter }); - setFileChooser(null); - }} + basePath={activeBasePath} + onSelect={selectFileFilter} onClose={() => setFileChooser(null)} /> )} - +
); } 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(
@@ -207,14 +275,7 @@ function MockFileChooserOverlay({ basePath, onSelect, onClose }: { basePath: str
-

Mock chooser for now. Later this will browse uploaded files below {basePath || "."}.

-
- {files.map((file) => ( - - ))} -
+