diff --git a/src/admin/admin.css b/src/admin/admin.css index 2047048..89f05a5 100644 --- a/src/admin/admin.css +++ b/src/admin/admin.css @@ -1338,3 +1338,80 @@ @media (max-width: 767px) { .bs-field-row { grid-template-columns: 1fr; } } + +/* ── Review panel summary (form: null resources) ────────────────── */ +.bs-summary { + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-3); +} +.bs-summary-row { + display: grid; + grid-template-columns: 100px 1fr; + gap: var(--space-3); + align-items: baseline; +} +.bs-summary-label { + font-family: var(--font-sans); + font-size: 10px; + letter-spacing: var(--tracking-wider); + text-transform: uppercase; + color: var(--on-surface-muted); + margin: 0; +} +.bs-summary-value { + font-size: 14px; + line-height: 1.5; + color: var(--on-surface); + margin: 0; +} + +/* ── Invite magic-link block ────────────────────────────────────── */ +.bs-invite-result { + background: rgba(109, 140, 124, 0.10); + border-radius: var(--radius-md); + padding: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-3); +} +.bs-invite-result-label { + font-family: var(--font-sans); + font-size: 11px; + letter-spacing: var(--tracking-wide); + color: #5a7268; + margin: 0; + font-weight: 500; +} +.bs-invite-link-row { + display: flex; + align-items: center; + gap: var(--space-2); +} +.bs-invite-link { + flex: 1; + font-family: var(--font-mono); + font-size: 12px; + background: var(--background); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + color: var(--on-surface); + word-break: break-all; + line-height: 1.4; +} +.bs-copy-btn { + flex-shrink: 0; + padding: 6px 12px; + background: var(--ink); + color: var(--on-ink); + border: none; + border-radius: 999px; + font-family: var(--font-sans); + font-size: 11px; + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + cursor: pointer; + transition: opacity var(--duration-fast) var(--ease-standard); +} +.bs-copy-btn:hover { opacity: 0.88; } diff --git a/src/admin/components/ResourceEditPanel.astro b/src/admin/components/ResourceEditPanel.astro index 02777be..7b70fb0 100644 --- a/src/admin/components/ResourceEditPanel.astro +++ b/src/admin/components/ResourceEditPanel.astro @@ -26,13 +26,34 @@ interface Props { const { resource, item, formValues, errors = {}, actingUserId } = Astro.props; -if (!resource.form) { - throw new Error(`ResourceEditPanel: ${resource.key} has form: null`); +const isCreate = item === null; +// Review mode = the panel is showing an existing item AND either the resource +// has no form, OR it has a summary that should be preferred over the form +// (e.g. invitations are write-once: form is for create, summary is for edit). +const isReviewMode = + !isCreate && + (resource.form === null || resource.summary !== undefined); +const singular = resource.singularLabel.toLowerCase(); +const title = isReviewMode + ? `Review ${singular}` + : isCreate + ? `New ${singular}` + : `Edit ${singular}`; + +// In review mode, the resource MUST have a summary function — that's what +// fills the panel body when the form is suppressed. +if (isReviewMode && !resource.summary) { + throw new Error( + `ResourceEditPanel: ${resource.key} is in review mode but has no summary — define resource.summary.`, + ); } -const isCreate = item === null; -const singular = resource.singularLabel.toLowerCase(); -const title = isCreate ? `New ${singular}` : `Edit ${singular}`; +// And the create path needs a form. +if (isCreate && !resource.form) { + throw new Error( + `ResourceEditPanel: cannot render create panel for ${resource.key} — form is null.`, + ); +} // Initial form values: prior failed submission > existing item > defaults const seedValues: Record = { ...(item ?? {}), ...(formValues ?? {}) }; @@ -56,11 +77,19 @@ function valueFor(field: Field): unknown { return resolveDefault(field); } -const visibleFields = resource.form.fields.filter( - (f) => !f.visibleWhen || f.visibleWhen(ctx), -); +const visibleFields = resource.form + ? resource.form.fields.filter((f) => !f.visibleWhen || f.visibleWhen(ctx)) + : []; -const embeds = resource.form.embeds ?? []; +const embeds = resource.form?.embeds ?? []; + +// Review-mode summary +const summaryEntries = isReviewMode && item + ? resource.summary!(item) + : []; + +// One-shot invite link surfaced after create/action — read from URL +const inviteUrl = Astro.url.searchParams.get('invite_url'); // Build the close URL — drop edit/new but keep filter/q/page const closeUrl = (() => { @@ -94,34 +123,59 @@ const formAction = Astro.url.pathname + Astro.url.search;
- {visibleFields.map((field) => ( - - ))} + {inviteUrl && ( +
+

Magic link — copy and send now. It will not be shown again.

+ +
+ )} - {embeds.length > 0 && embeds.map((embed) => { - const show = !embed.visibleWhen || embed.visibleWhen(ctx); - if (!show) return null; - return ( -
-

{embed.title}

- {embed.component === 'pulse-sub-form' && ( - - )} -
- ); - })} + {isReviewMode ? ( +
+ {summaryEntries.map((entry) => ( +
+
{entry.label}
+
{entry.value}
+
+ ))} +
+ ) : ( + <> + {visibleFields.map((field) => ( + + ))} + + {embeds.length > 0 && embeds.map((embed) => { + const show = !embed.visibleWhen || embed.visibleWhen(ctx); + if (!show) return null; + return ( +
+

{embed.title}

+ {embed.component === 'pulse-sub-form' && ( + + )} +
+ ); + })} + + )}