diff --git a/src/components/LoadingFrame.tsx b/src/components/LoadingFrame.tsx new file mode 100644 index 0000000..faf71af --- /dev/null +++ b/src/components/LoadingFrame.tsx @@ -0,0 +1,26 @@ +import LoadingIndicator from "./LoadingIndicator"; + +type LoadingFrameProps = { + children: React.ReactNode; + loading?: boolean; + label?: string; + className?: string; +}; + +export default function LoadingFrame({ children, loading = false, label = "Loading data…", className = "" }: LoadingFrameProps) { + const classNames = ["loading-frame", loading ? "is-loading" : "", className].filter(Boolean).join(" "); + + return ( +
+ {children} + {loading && ( +
+
+ + {label} +
+
+ )} +
+ ); +} diff --git a/src/features/campaigns/AttachmentsDataPage.tsx b/src/features/campaigns/AttachmentsDataPage.tsx index acf8c7c..9d57a98 100644 --- a/src/features/campaigns/AttachmentsDataPage.tsx +++ b/src/features/campaigns/AttachmentsDataPage.tsx @@ -4,6 +4,7 @@ import type { ApiSettings } from "../../types"; import Button from "../../components/Button"; import Card from "../../components/Card"; import PageTitle from "../../components/PageTitle"; +import LoadingFrame from "../../components/LoadingFrame"; import ToggleSwitch from "../../components/ToggleSwitch"; import { autosaveCampaignVersion } from "../../api/campaigns"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; @@ -23,11 +24,12 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings const [pathChooser, setPathChooser] = useState(null); const version = data.currentVersion; const locked = isAuditLockedVersion(version); - const attachments = asRecord(draft?.attachments); + const displayDraft = draft ?? ensureCampaignDraft(null); + const attachments = asRecord(displayDraft.attachments); const basePaths = useMemo(() => normalizeBasePaths(attachments.base_paths, attachments), [attachments]); const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]); const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]); - const entries = asRecord(draft?.entries); + const entries = asRecord(displayDraft.entries); const inlineEntries = asArray(entries.inline).map(asRecord); const individualRules = inlineEntries.flatMap((entry, index) => asArray(entry.attachments).map((rule) => ({ entry: String(entry.id || index + 1), ...asRecord(rule) }))); @@ -136,7 +138,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings {localError &&
{localError}
} {locked &&
This version is read-only. {versionLockReason(version)}
} - {draft && ( + <> saveDraft("manual")} disabled={!dirty || locked}>Save - )} + {pathChooser && ( {error}} +

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

+
); } diff --git a/src/features/campaigns/CampaignDataPage.tsx b/src/features/campaigns/CampaignDataPage.tsx index 12c8a99..1e87768 100644 --- a/src/features/campaigns/CampaignDataPage.tsx +++ b/src/features/campaigns/CampaignDataPage.tsx @@ -5,6 +5,7 @@ import Button from "../../components/Button"; import Card from "../../components/Card"; import FormField from "../../components/FormField"; import PageTitle from "../../components/PageTitle"; +import LoadingFrame from "../../components/LoadingFrame"; import ToggleSwitch from "../../components/ToggleSwitch"; import EmailAddressInput from "../../components/email/EmailAddressInput"; import { addressesFromValue, collectCampaignAddressSuggestions, type MailboxAddress } from "../../utils/emailAddresses"; @@ -25,12 +26,13 @@ export default function CampaignDataPage({ settings, campaignId }: { settings: A const version = data.currentVersion; const locked = isAuditLockedVersion(version); - const campaign = asRecord(draft?.campaign); - const recipients = asRecord(draft?.recipients); + const displayDraft = draft ?? ensureCampaignDraft(null); + const campaign = asRecord(displayDraft.campaign); + const recipients = asRecord(displayDraft.recipients); const from = asRecord(recipients.from); const defaultFrom = addressesFromValue(from); const globalReplyTo = addressesFromValue(recipients.reply_to); - const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(draft), [draft]); + const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]); useEffect(() => { if (!version) return; @@ -97,7 +99,7 @@ export default function CampaignDataPage({ settings, campaignId }: { settings: A {localError &&
{localError}
} {locked &&
This version is read-only. {versionLockReason(version)} Copy the campaign before editing general campaign data.
} - {draft && ( + <>
@@ -195,7 +197,7 @@ export default function CampaignDataPage({ settings, campaignId }: { settings: A
- )} +
); } diff --git a/src/features/campaigns/CampaignFieldsPage.tsx b/src/features/campaigns/CampaignFieldsPage.tsx index 83df22a..89f2abe 100644 --- a/src/features/campaigns/CampaignFieldsPage.tsx +++ b/src/features/campaigns/CampaignFieldsPage.tsx @@ -4,6 +4,7 @@ import Button from "../../components/Button"; import Card from "../../components/Card"; import FormField from "../../components/FormField"; import PageTitle from "../../components/PageTitle"; +import LoadingFrame from "../../components/LoadingFrame"; import ToggleSwitch from "../../components/ToggleSwitch"; import { autosaveCampaignVersion } from "../../api/campaigns"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; @@ -33,8 +34,9 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings: const version = data.currentVersion; const locked = isAuditLockedVersion(version); - const fields = useMemo(() => normalizeFields(draft?.fields), [draft?.fields]); - const globalValues = asRecord(draft?.global_values); + const displayDraft = draft ?? ensureCampaignDraft(null); + const fields = useMemo(() => normalizeFields(displayDraft.fields), [displayDraft.fields]); + const globalValues = asRecord(displayDraft.global_values); const fieldNameWarning = useMemo(() => describeFieldNameProblem(fields), [fields]); const canSave = dirty && !locked && Boolean(draft) && !fieldNameWarning; @@ -193,7 +195,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings: {fieldNameWarning &&
{fieldNameWarning}
} {locked &&
This version is read-only. {versionLockReason(version)} Copy the campaign before editing fields.
} - {draft && ( + <> saveDraft("manual")} disabled={!canSave}>Save - )} + ); } diff --git a/src/features/campaigns/CampaignJsonView.tsx b/src/features/campaigns/CampaignJsonView.tsx index 513edc6..c6f07fb 100644 --- a/src/features/campaigns/CampaignJsonView.tsx +++ b/src/features/campaigns/CampaignJsonView.tsx @@ -2,6 +2,7 @@ import type { ApiSettings } from "../../types"; import Card from "../../components/Card"; import Button from "../../components/Button"; import PageTitle from "../../components/PageTitle"; +import LoadingFrame from "../../components/LoadingFrame"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { asRecord, formatDateTime, getCampaignJson } from "./utils/campaignView"; import { downloadJson, safeFileStem } from "./utils/draftEditor"; @@ -26,9 +27,11 @@ export default function CampaignJsonView({ settings, campaignId }: { settings: A {error &&
{error}
} + {!loading || version ?
{JSON.stringify(campaignJson, null, 2)}
:
{"{}"}
}
+
); } diff --git a/src/features/campaigns/CampaignListPage.tsx b/src/features/campaigns/CampaignListPage.tsx index f5a469d..2fc2f2b 100644 --- a/src/features/campaigns/CampaignListPage.tsx +++ b/src/features/campaigns/CampaignListPage.tsx @@ -5,6 +5,7 @@ import Card from "../../components/Card"; import Button from "../../components/Button"; import StatusBadge from "../../components/StatusBadge"; import PageTitle from "../../components/PageTitle"; +import LoadingFrame from "../../components/LoadingFrame"; import { createNewCampaign, listCampaigns } from "../../api/campaigns"; import type { CampaignListItem } from "../../types"; @@ -72,7 +73,8 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings } - {!loading && campaigns.length === 0 && ( + + {campaigns.length === 0 && (

No campaigns yet

Start with a guided campaign draft. The WebUI will create a portable campaign JSON in the background.

@@ -81,7 +83,7 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
)} - {!loading && campaigns.length > 0 && ( + {campaigns.length > 0 && (
@@ -114,6 +116,7 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
)} +
); diff --git a/src/features/campaigns/CampaignOverviewPage.tsx b/src/features/campaigns/CampaignOverviewPage.tsx index 7afd638..d34f24f 100644 --- a/src/features/campaigns/CampaignOverviewPage.tsx +++ b/src/features/campaigns/CampaignOverviewPage.tsx @@ -5,6 +5,7 @@ import Button from "../../components/Button"; import Card from "../../components/Card"; import MetricCard from "../../components/MetricCard"; import PageTitle from "../../components/PageTitle"; +import LoadingFrame from "../../components/LoadingFrame"; import { createNewCampaign, publishCampaignVersion, updateCampaignVersion } from "../../api/campaigns"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { @@ -106,7 +107,8 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting {error &&
{error}
} {message &&
{message}
} - + + {locked && (
@@ -192,6 +194,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting

Review and sending are displayed on their own data pages; use the guided buttons above to change state.

+
); } diff --git a/src/features/campaigns/CampaignReportPage.tsx b/src/features/campaigns/CampaignReportPage.tsx index 5666e41..d20ba8f 100644 --- a/src/features/campaigns/CampaignReportPage.tsx +++ b/src/features/campaigns/CampaignReportPage.tsx @@ -2,6 +2,7 @@ import type { ApiSettings } from "../../types"; import Card from "../../components/Card"; import Button from "../../components/Button"; import PageTitle from "../../components/PageTitle"; +import LoadingFrame from "../../components/LoadingFrame"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { formatDateTime } from "./utils/campaignView"; @@ -22,6 +23,7 @@ export default function CampaignReportPage({ settings, campaignId }: { settings: {error &&
{error}
} +
@@ -35,6 +37,7 @@ export default function CampaignReportPage({ settings, campaignId }: { settings:

CSV export and report-emailing buttons will be added once the report section is reviewed.

+
); } diff --git a/src/features/campaigns/GlobalSettingsPage.tsx b/src/features/campaigns/GlobalSettingsPage.tsx index 1700cbf..532ef57 100644 --- a/src/features/campaigns/GlobalSettingsPage.tsx +++ b/src/features/campaigns/GlobalSettingsPage.tsx @@ -4,6 +4,7 @@ import Button from "../../components/Button"; import Card from "../../components/Card"; import FormField from "../../components/FormField"; import PageTitle from "../../components/PageTitle"; +import LoadingFrame from "../../components/LoadingFrame"; import ToggleSwitch from "../../components/ToggleSwitch"; import { autosaveCampaignVersion } from "../../api/campaigns"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; @@ -25,12 +26,13 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings: const version = data.currentVersion; const locked = isAuditLockedVersion(version); - const validationPolicy = asRecord(draft?.validation_policy); - const attachments = asRecord(draft?.attachments); - const delivery = asRecord(draft?.delivery); + const displayDraft = draft ?? ensureCampaignDraft(null); + const validationPolicy = asRecord(displayDraft.validation_policy); + const attachments = asRecord(displayDraft.attachments); + const delivery = asRecord(displayDraft.delivery); const rateLimit = asRecord(delivery.rate_limit); const retry = asRecord(delivery.retry); - const statusTracking = asRecord(draft?.status_tracking); + const statusTracking = asRecord(displayDraft.status_tracking); const optIns = asRecord(editorState.opt_ins); useEffect(() => { @@ -108,7 +110,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings: {localError &&
{localError}
} {locked &&
This version is read-only. {versionLockReason(version)} Copy the campaign before editing global settings.
} - {draft && ( + <>
@@ -158,7 +160,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
- )} +
); } diff --git a/src/features/campaigns/MailSettingsPage.tsx b/src/features/campaigns/MailSettingsPage.tsx index 0a18b69..86bcc5a 100644 --- a/src/features/campaigns/MailSettingsPage.tsx +++ b/src/features/campaigns/MailSettingsPage.tsx @@ -4,6 +4,7 @@ import Button from "../../components/Button"; import Card from "../../components/Card"; import FormField from "../../components/FormField"; import PageTitle from "../../components/PageTitle"; +import LoadingFrame from "../../components/LoadingFrame"; import ToggleSwitch from "../../components/ToggleSwitch"; import { autosaveCampaignVersion } from "../../api/campaigns"; import { listImapFolders, testImapSettings, testSmtpSettings, type MailConnectionTestResponse, type MailImapFolderListResponse, type MailSecurity } from "../../api/mail"; @@ -27,10 +28,11 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A const version = data.currentVersion; const locked = isAuditLockedVersion(version); - const server = asRecord(draft?.server); + const displayDraft = draft ?? ensureCampaignDraft(null); + const server = asRecord(displayDraft.server); const smtp = asRecord(server.smtp); const imap = asRecord(server.imap); - const delivery = asRecord(draft?.delivery); + const delivery = asRecord(displayDraft.delivery); const imapAppend = asRecord(delivery.imap_append_sent); const imapEnabled = getBool(imap, "enabled"); const imapDisabled = locked || !imapEnabled; @@ -189,7 +191,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A {localError &&
{localError}
} {locked &&
This version is read-only. {versionLockReason(version)} Copy the campaign before editing server settings.
} - {draft && ( + <>
@@ -245,7 +247,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
- )} +
); } diff --git a/src/features/campaigns/RecipientDataPage.tsx b/src/features/campaigns/RecipientDataPage.tsx index 08526f8..de4f6b0 100644 --- a/src/features/campaigns/RecipientDataPage.tsx +++ b/src/features/campaigns/RecipientDataPage.tsx @@ -4,6 +4,7 @@ import Button from "../../components/Button"; import Card from "../../components/Card"; import FormField from "../../components/FormField"; import PageTitle from "../../components/PageTitle"; +import LoadingFrame from "../../components/LoadingFrame"; import ToggleSwitch from "../../components/ToggleSwitch"; import EmailAddressInput from "../../components/email/EmailAddressInput"; import { autosaveCampaignVersion } from "../../api/campaigns"; @@ -41,18 +42,19 @@ export default function RecipientDataPage({ settings, campaignId }: { settings: const version = data.currentVersion; const locked = isAuditLockedVersion(version); - const recipientsSection = asRecord(draft?.recipients); - const entries = asRecord(draft?.entries); + const displayDraft = draft ?? ensureCampaignDraft(null); + const recipientsSection = asRecord(displayDraft.recipients); + const entries = asRecord(displayDraft.entries); 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 fieldDefinitions = useMemo(() => getDraftFields(displayDraft), [displayDraft]); + const attachmentSection = asRecord(displayDraft.attachments); + const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [attachmentSection]); 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 addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]); const globalRecipientValues: Record = { to: addressesFromValue(recipientsSection.to), cc: addressesFromValue(recipientsSection.cc), @@ -180,7 +182,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings: {localError &&
{localError}
} {locked &&
This version is read-only. {versionLockReason(version)}
} - {draft && ( + <>
@@ -214,11 +216,11 @@ export default function RecipientDataPage({ settings, campaignId }: { settings: title="Recipients" actions={[, ]} > - {draft && inlineEntries.length === 0 && !source.type &&

No recipient data is stored in the current version yet.

} - {draft && inlineEntries.length === 0 && Boolean(source.type) && ( + {inlineEntries.length === 0 && !source.type &&

No recipient data is stored in the current version yet.

} + {inlineEntries.length === 0 && Boolean(source.type) && (
This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.
)} - {draft && inlineEntries.length > 0 && ( + {inlineEntries.length > 0 && (
@@ -328,7 +330,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings: - )} + ); } diff --git a/src/features/campaigns/ReviewDataPage.tsx b/src/features/campaigns/ReviewDataPage.tsx index 05f2a8e..ebacbed 100644 --- a/src/features/campaigns/ReviewDataPage.tsx +++ b/src/features/campaigns/ReviewDataPage.tsx @@ -2,6 +2,7 @@ import { Link } from "react-router-dom"; import type { ApiSettings } from "../../types"; import Button from "../../components/Button"; import PageTitle from "../../components/PageTitle"; +import LoadingFrame from "../../components/LoadingFrame"; import Card from "../../components/Card"; import StatusBadge from "../../components/StatusBadge"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; @@ -27,6 +28,7 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api {error &&
{error}
} +
@@ -74,6 +76,7 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
)}
+
); } diff --git a/src/features/campaigns/SendDataPage.tsx b/src/features/campaigns/SendDataPage.tsx index e3816dd..ba1fab6 100644 --- a/src/features/campaigns/SendDataPage.tsx +++ b/src/features/campaigns/SendDataPage.tsx @@ -2,6 +2,7 @@ import { Link } from "react-router-dom"; import type { ApiSettings } from "../../types"; import Button from "../../components/Button"; import PageTitle from "../../components/PageTitle"; +import LoadingFrame from "../../components/LoadingFrame"; import Card from "../../components/Card"; import MetricCard from "../../components/MetricCard"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; @@ -31,6 +32,7 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe {error &&
{error}
} +
@@ -63,6 +65,7 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe SMTP sending and IMAP append-to-Sent remain separate states. A successful SMTP send is still successful even if appending to Sent fails.

+
); } diff --git a/src/features/campaigns/TemplateDataPage.tsx b/src/features/campaigns/TemplateDataPage.tsx index fa28485..21eae73 100644 --- a/src/features/campaigns/TemplateDataPage.tsx +++ b/src/features/campaigns/TemplateDataPage.tsx @@ -5,9 +5,10 @@ import Button from "../../components/Button"; import Card from "../../components/Card"; import FormField from "../../components/FormField"; import PageTitle from "../../components/PageTitle"; +import LoadingFrame from "../../components/LoadingFrame"; import { autosaveCampaignVersion } from "../../api/campaigns"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; -import { asArray, asRecord, formatDateTime, getTemplateSection, isAuditLockedVersion, versionLockReason } from "./utils/campaignView"; +import { asArray, asRecord, formatDateTime, isAuditLockedVersion, versionLockReason } from "./utils/campaignView"; import { cloneJson, ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor"; import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext"; @@ -38,23 +39,23 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A const [previewOpen, setPreviewOpen] = useState(false); const [previewIndex, setPreviewIndex] = useState(0); const [undefinedDialog, setUndefinedDialog] = useState(null); - const loadedVersionId = useRef(null); const subjectRef = useRef(null); const textRef = useRef(null); const htmlRef = useRef(null); const version = data.currentVersion; const locked = isAuditLockedVersion(version); - const template = draft ? asRecord(draft.template) : getTemplateSection(version); - const fields = useMemo(() => asArray(draft?.fields).map(asRecord), [draft]); + const displayDraft = draft ?? ensureCampaignDraft(null); + const template = asRecord(displayDraft.template); + const fields = useMemo(() => asArray(displayDraft.fields).map(asRecord), [displayDraft.fields]); const localFieldNames = useMemo(() => fields.map((field) => String(field.name || field.id || "")).filter(Boolean), [fields]); - const globalFieldNames = useMemo(() => uniqueSorted([...localFieldNames, ...Object.keys(asRecord(draft?.global_values))]), [draft?.global_values, localFieldNames]); + const globalFieldNames = useMemo(() => uniqueSorted([...localFieldNames, ...Object.keys(asRecord(displayDraft.global_values))]), [displayDraft.global_values, localFieldNames]); const allAvailableNames = useMemo(() => new Set([...localFieldNames, ...globalFieldNames]), [localFieldNames, globalFieldNames]); - const entries = asRecord(draft?.entries); + const entries = asRecord(displayDraft.entries); const inlineEntries = useMemo(() => asArray(entries.inline).map(asRecord), [entries.inline]); const previewEntries = inlineEntries.length > 0 ? inlineEntries : [{}]; const previewEntry = previewEntries[Math.min(previewIndex, previewEntries.length - 1)] ?? {}; - const ignoreEmptyFields = getBool(asRecord(draft?.validation_policy), "ignore_empty_fields", false); + const ignoreEmptyFields = getBool(asRecord(displayDraft.validation_policy), "ignore_empty_fields", false); const templateText = `${getText(template, "subject")}\n${getText(template, "text")}\n${getText(template, "html")}`; const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]); const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]); @@ -64,15 +65,13 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A ...field, reason: field.validNamespace ? "missing-field" : "invalid-namespace" }))), [usedPlaceholders, allAvailableNames]); - const previewContext = useMemo(() => buildPreviewContext(draft, previewEntry), [draft, previewEntry]); + const previewContext = useMemo(() => buildPreviewContext(displayDraft, previewEntry), [displayDraft, previewEntry]); const previewSubject = renderPreviewText(getText(template, "subject"), previewContext, ignoreEmptyFields); const previewText = renderPreviewText(getText(template, "text"), previewContext, ignoreEmptyFields); const previewHtml = renderPreviewText(getText(template, "html"), previewContext, ignoreEmptyFields); useEffect(() => { if (!version) return; - if (loadedVersionId.current === version.id) return; - loadedVersionId.current = version.id; setDraft(ensureCampaignDraft(version)); setDirty(false); setPreviewIndex(0); @@ -196,7 +195,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A {localError &&
{localError}
} {locked &&
This version is read-only. {versionLockReason(version)}
} - {draft && ( + <>
setPreviewOpen(true)}>Preview}> @@ -281,7 +280,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
- )} +
{previewOpen && ( :not(.loading-frame-overlay) { + pointer-events: none; + user-select: none; +} + +.loading-frame-overlay { + position: absolute; + inset: 0; + z-index: 30; + display: grid; + place-items: center; + min-height: 120px; + padding: 1.25rem; + border-radius: var(--radius-lg, 18px); + background: rgba(255, 255, 255, 0.00); + backdrop-filter: blur(1.5px); + margin: -10px; +} + +.loading-frame-panel { + display: inline-flex; + align-items: center; + gap: 0.65rem; + padding: 0.75rem 1rem; + border: 1px solid var(--border-soft, rgba(15, 23, 42, 0.12)); + border-radius: 999px; + color: var(--text, #172033); + background: rgba(255, 255, 255, 0.86); + box-shadow: var(--shadow-soft, 0 10px 28px rgba(15, 23, 42, 0.12)); + font-size: 0.9rem; + font-weight: 600; +}