@cosmicdrift/kumiko-document-designer 0.377.0 → 0.378.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,7 @@ export declare const PLATFORM_TEMPLATES_NAV_ID: "platform-templates";
9
9
  export declare const BRANDING_SCREEN_QN: "managed-pages:screen:branding-settings";
10
10
  export declare const BRANDING_QUERY_QN: "managed-pages:query:branding";
11
11
  export declare const USER_ME_QUERY_QN: "user:query:user:me";
12
+ export declare const FILES_FEATURE_NAME: "files";
12
13
  export declare const PROFILE_FEATURE_NAME: "user-profile";
13
14
  export declare const PROFILE_SCREEN_ID: "profile";
14
15
  export declare const DEFAULT_LAYOUT_SLUG: "default";
package/dist/constants.js CHANGED
@@ -12,6 +12,8 @@ export const BRANDING_SCREEN_QN = "managed-pages:screen:branding-settings";
12
12
  export const BRANDING_QUERY_QN = "managed-pages:query:branding";
13
13
  // Pinned like the branding names: the user features' constants are not exported to the web barrel.
14
14
  export const USER_ME_QUERY_QN = "user:query:user:me";
15
+ // Pinned like the profile names: the framework's files constant is not exported to the web barrel.
16
+ export const FILES_FEATURE_NAME = "files";
15
17
  export const PROFILE_FEATURE_NAME = "user-profile";
16
18
  export const PROFILE_SCREEN_ID = "profile";
17
19
  export const DEFAULT_LAYOUT_SLUG = "default";
package/dist/feature.d.ts CHANGED
@@ -1,2 +1,6 @@
1
- import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
- export declare function createDocumentDesignerFeature(): FeatureDefinition;
1
+ import { type FeatureDefinition, type RoleAccessRule } from "@cosmicdrift/kumiko-framework/engine";
2
+ export type DocumentDesignerOptions = {
3
+ /** Roles besides SystemAdmin that see the tenant overview and the editors. Pass `templateEditAccess(tenantEditable)` of the template-resolver mount so the screens match the templates a tenant may edit. */
4
+ readonly access?: RoleAccessRule;
5
+ };
6
+ export declare function createDocumentDesignerFeature(options?: DocumentDesignerOptions): FeatureDefinition;
package/dist/feature.js CHANGED
@@ -3,10 +3,10 @@ import { DOCUMENT_DESIGNER_FEATURE, LAYOUT_EDITOR_SCREEN_ID, MAIL_EDITOR_SCREEN_
3
3
  import { DOCUMENT_DESIGNER_I18N } from "./i18n.js";
4
4
  import { TEMPLATE_FACETS, templateColumns, templateRowActions } from "./list-definition.js";
5
5
  const OVERVIEW_QUERY = "template-resolver:query:overview";
6
- const TENANT_ROLES = ["TenantAdmin", "SystemAdmin"];
7
- export function createDocumentDesignerFeature() {
6
+ export function createDocumentDesignerFeature(options = {}) {
7
+ const editorRoles = [...new Set(["SystemAdmin", ...(options.access?.roles ?? [])])];
8
8
  return defineFeature(DOCUMENT_DESIGNER_FEATURE, (r) => {
9
- r.describe("Template overview and layout editor: lists the mail, PDF and layout templates of a tenant (platform admins see the system templates and how many tenants customised each) and edits the mail layout and the PDF letterhead in the browser. Mount after template-resolver and, for the branding rail, managed-pages. Client: `documentDesignerClient()`; admin-shell lists the navs with `includeDocumentDesigner`.");
9
+ r.describe("Template overview and layout editor: lists the mail, PDF and layout templates of a tenant (platform admins see the system templates and how many tenants customised each) and edits the mail layout and the PDF letterhead in the browser. Mount after template-resolver and, for the branding rail, managed-pages. Client: `documentDesignerClient()`; admin-shell lists the navs with `includeDocumentDesigner`. The tenant overview and the editors are for SystemAdmin only; the `access` option adds roles (normally `templateEditAccess(tenantEditable)` of the template-resolver mount), and the server still enforces per template which of them may save.");
10
10
  r.uiHints({
11
11
  displayLabel: "Documents · Template Designer",
12
12
  category: "content",
@@ -25,14 +25,14 @@ export function createDocumentDesignerFeature() {
25
25
  searchPlaceholder: i18nKey("documentDesigner.search"),
26
26
  pagination: "pages",
27
27
  defaultSort: { field: "modifiedAt", dir: "desc" },
28
- access: { roles: access.roles(...TENANT_ROLES) },
28
+ access: { roles: access.roles(...editorRoles) },
29
29
  });
30
30
  r.nav({
31
31
  id: TEMPLATES_NAV_ID,
32
32
  label: "document-designer:nav.templates",
33
33
  icon: "file",
34
34
  screen: `${DOCUMENT_DESIGNER_FEATURE}:screen:${TEMPLATES_SCREEN_ID}`,
35
- access: { roles: access.roles(...TENANT_ROLES) },
35
+ access: { roles: access.roles(...editorRoles) },
36
36
  order: 15,
37
37
  });
38
38
  r.screen({
@@ -62,7 +62,7 @@ export function createDocumentDesignerFeature() {
62
62
  renderer: { react: { __component: LAYOUT_EDITOR_SCREEN_ID } },
63
63
  description: "documentDesigner.screen.layoutEditor.description",
64
64
  dormant: true,
65
- access: { roles: access.roles(...TENANT_ROLES) },
65
+ access: { roles: access.roles(...editorRoles) },
66
66
  });
67
67
  r.screen({
68
68
  id: PDF_TEMPLATE_EDITOR_SCREEN_ID,
@@ -70,7 +70,7 @@ export function createDocumentDesignerFeature() {
70
70
  renderer: { react: { __component: PDF_TEMPLATE_EDITOR_SCREEN_ID } },
71
71
  description: "Editor for one PDF template: edit the body, print a preview through the print service, review tenant customisations and export the template as a bundle.",
72
72
  dormant: true,
73
- access: { roles: access.roles(...TENANT_ROLES) },
73
+ access: { roles: access.roles(...editorRoles) },
74
74
  });
75
75
  // kumiko-lint-ignore app-feature-structure Board-2/3 mail editor (placeholder rail, chip editors, live preview, test mail) has no declarative screen type
76
76
  r.screen({
@@ -79,7 +79,7 @@ export function createDocumentDesignerFeature() {
79
79
  renderer: { react: { __component: MAIL_EDITOR_SCREEN_ID } },
80
80
  description: "documentDesigner.screen.mailEditor.description",
81
81
  dormant: true,
82
- access: { roles: access.roles(...TENANT_ROLES) },
82
+ access: { roles: access.roles(...editorRoles) },
83
83
  });
84
84
  });
85
85
  }
package/dist/i18n.js CHANGED
@@ -397,6 +397,18 @@ export const DOCUMENT_DESIGNER_I18N = {
397
397
  en: "Save first, then upload.",
398
398
  de: "Erst speichern, dann hochladen.",
399
399
  },
400
+ "documentDesigner.mail.resource.noFilesFeature": {
401
+ en: "Uploads need the files feature, which this app has not mounted.",
402
+ de: "Uploads brauchen das Files-Feature, das diese App nicht eingebunden hat.",
403
+ },
404
+ "documentDesigner.mail.testMail.unavailable.noChannel": {
405
+ en: "Test mails need the email channel, which this app has not mounted.",
406
+ de: "Test-Mails brauchen den E-Mail-Kanal, den diese App nicht eingebunden hat.",
407
+ },
408
+ "documentDesigner.mail.testMail.unavailable.noAddress": {
409
+ en: "No email address is stored for you, so no test mail can be sent.",
410
+ de: "Für dich ist keine E-Mail-Adresse hinterlegt, daher kann keine Test-Mail gesendet werden.",
411
+ },
400
412
  "documentDesigner.mail.resource.inline": {
401
413
  en: "Inline",
402
414
  de: "Inline",
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export { DEFAULT_TEMPLATE_LOCALES, DOCUMENT_DESIGNER_FEATURE, LAYOUT_EDITOR_SCREEN_ID, MAIL_EDITOR_SCREEN_ID, PDF_TEMPLATE_EDITOR_SCREEN_ID, PLATFORM_TEMPLATES_NAV_ID, PLATFORM_TEMPLATES_SCREEN_ID, TEMPLATE_EDITOR_SCREENS, TEMPLATES_NAV_ID, TEMPLATES_SCREEN_ID, type TemplateEditorKind, } from "./constants.ts";
2
- export { createDocumentDesignerFeature } from "./feature.ts";
2
+ export { createDocumentDesignerFeature, type DocumentDesignerOptions } from "./feature.ts";
package/dist/web/i18n.js CHANGED
@@ -35,10 +35,9 @@ export const documentDesignerTranslations = {
35
35
  "documentDesigner.pdfUnavailableNoReason": "PDF generation is not available.",
36
36
  "documentDesigner.placeholders.layoutHint": 'Images travel with the PDF as data URIs. Header and footer come from the layout "{layout}" and are the same on every page.',
37
37
  "documentDesigner.preview.loading": "Generating PDF...",
38
- "documentDesigner.preview.pageOf": "Page {page} of {pages}",
39
- "documentDesigner.preview.renderFailed": "The PDF could not be displayed.",
40
38
  "documentDesigner.preview.stale": "The preview is out of date. Generate the PDF again.",
41
39
  "documentDesigner.resources.empty": "No images yet.",
40
+ "documentDesigner.resources.noFilesFeature": "Uploads need the files feature, which this app has not mounted.",
42
41
  "documentDesigner.resources.saveFirst": "Save the template first, then you can upload images.",
43
42
  "documentDesigner.resources.title": "Resources",
44
43
  "documentDesigner.resources.upload": "+ Upload",
@@ -108,10 +107,9 @@ export const documentDesignerTranslations = {
108
107
  "documentDesigner.pdfUnavailableNoReason": "PDF-Erzeugung nicht verfügbar.",
109
108
  "documentDesigner.placeholders.layoutHint": 'Bilder gehen im PDF als Data-URI mit. Kopf- und Fußzeile kommen aus dem Layout "{layout}" und sind auf allen Seiten gleich.',
110
109
  "documentDesigner.preview.loading": "PDF wird erzeugt ...",
111
- "documentDesigner.preview.pageOf": "Seite {page} von {pages}",
112
- "documentDesigner.preview.renderFailed": "Das PDF konnte nicht angezeigt werden.",
113
110
  "documentDesigner.preview.stale": "Die Vorschau ist veraltet. Bitte das PDF neu erzeugen.",
114
111
  "documentDesigner.resources.empty": "Noch keine Bilder.",
112
+ "documentDesigner.resources.noFilesFeature": "Uploads brauchen das Files-Feature, das diese App nicht eingebunden hat.",
115
113
  "documentDesigner.resources.saveFirst": "Speichere die Vorlage zuerst, danach können Bilder hochgeladen werden.",
116
114
  "documentDesigner.resources.title": "Ressourcen",
117
115
  "documentDesigner.resources.upload": "+ Hochladen",
@@ -181,10 +179,9 @@ export const documentDesignerTranslations = {
181
179
  "documentDesigner.pdfUnavailableNoReason": "La generación de PDF no está disponible.",
182
180
  "documentDesigner.placeholders.layoutHint": "Las imágenes van incluidas en el PDF como URI de datos. El encabezado y el pie de página vienen del diseño «{layout}» y son iguales en todas las páginas.",
183
181
  "documentDesigner.preview.loading": "Generando PDF...",
184
- "documentDesigner.preview.pageOf": "Página {page} de {pages}",
185
- "documentDesigner.preview.renderFailed": "No se pudo mostrar el PDF.",
186
182
  "documentDesigner.preview.stale": "La vista previa está desactualizada. Vuelve a generar el PDF.",
187
183
  "documentDesigner.resources.empty": "Todavía no hay imágenes.",
184
+ "documentDesigner.resources.noFilesFeature": "Las subidas necesitan la función de archivos, que esta app no ha montado.",
188
185
  "documentDesigner.resources.saveFirst": "Guarda primero la plantilla; después podrás subir imágenes.",
189
186
  "documentDesigner.resources.title": "Recursos",
190
187
  "documentDesigner.resources.upload": "+ Subir",
@@ -143,8 +143,8 @@ export function LayoutEditorBody({ kind, slug, isSystemAdmin, source, overviewRo
143
143
  };
144
144
  const accentColor = branding.data?.accentColor ?? "";
145
145
  const showAccentLine = isMail && appearanceEditable && appearance.accentLine === "branding" && isHexColor(accentColor);
146
- // A layout region has no per-mail variables, so a steps block there could never show a stage.
147
- const blocks = (isMail ? MAIL_DOCUMENT_BLOCKS : PDF_DOCUMENT_BLOCKS).filter((block) => block !== "steps");
146
+ // A layout region has no per-mail variables, so a steps block there could never show a stage and an images block no photos.
147
+ const blocks = (isMail ? MAIL_DOCUMENT_BLOCKS : PDF_DOCUMENT_BLOCKS).filter((block) => block !== "steps" && block !== "images");
148
148
  // The layout's "logo" resource is the branding logo, so the editor shows it instead of a chip.
149
149
  const logoFile = branding.data?.logoFile ?? "";
150
150
  const resourcePreviews = logoFile === ""
@@ -18,6 +18,7 @@ import { MailPreviewMode } from "./mail-preview.js";
18
18
  import { AttachmentSection, ChromeSection, PropertiesSection, UsageSection, } from "./mail-right-rail.js";
19
19
  import { UploadModal } from "./mail-upload-modal.js";
20
20
  import { VersionsSection } from "./mail-versions.js";
21
+ import { useFilesCapability } from "./use-files-capability.js";
21
22
  import { useInsertTargets } from "./use-insert-targets.js";
22
23
  import { useMailPersistence } from "./use-mail-persistence.js";
23
24
  import { useOverviewRows } from "./use-overview-rows.js";
@@ -66,6 +67,7 @@ export function MailEditorBody(props) {
66
67
  });
67
68
  const testMail = useTestMail();
68
69
  const own = useOwnAddress();
70
+ const filesAvailable = useFilesCapability();
69
71
  const resourceRemove = useMutation(TemplateResolverHandlers.resourceRemove);
70
72
  const layouts = useOverviewRows("mail-layout");
71
73
  const pdfTemplates = useOverviewRows("document-pdf");
@@ -175,7 +177,7 @@ export function MailEditorBody(props) {
175
177
  return (_jsxs(EditorFrame, { testId: "document-designer-mail-editor", children: [_jsx(EditorHeader, { title: title, stored: row === null ? null : { status: row.status, version: row.version }, actions: { busy, canSave, canPublish, onSave: saveDraft, onPublish: publish }, children: _jsx(EditorModeSwitch, { testIdPrefix: "mail", mode: mode, onMode: setMode }) }), mode === "edit" && (_jsx(MailOriginBar, { row: row, systemVersion: props.systemVersion, locale: locale, isSystemAdmin: isSystemAdmin, creating: creating, controls: controls, onReload: () => {
176
178
  setResetError(null);
177
179
  void onReload();
178
- }, onResetError: setResetError })), testMail.state.kind === "sent" && (_jsx(Banner, { variant: "primary", testId: "mail-test-sent", children: t("documentDesigner.mail.testMail.sent", { subject: testMail.state.subject }) })), testMail.state.kind === "error" && (_jsx(Banner, { variant: "error", testId: "mail-test-error", children: testMail.state.text })), resetError !== null && (_jsx(Banner, { variant: "error", testId: "mail-reset-error", children: resetError })), resourceError !== null && (_jsx(Banner, { variant: "error", testId: "mail-resource-error", children: resourceError })), mode === "edit" ? (_jsx(EditorBody, { left: _jsxs(_Fragment, { children: [_jsx(PlaceholderSection, { validation: railValidation, onInsert: insert.insertPlaceholder }), _jsx(SystemPlaceholderSection, { validation: railValidation, onInsert: insert.insertPlaceholder }), _jsx(ResourceSection, { resources: resources, pdfTitles: pdfTitles, canUpload: uploadTemplateId !== null, onUpload: () => setUploadOpen(true), onInsert: insert.insertResource, onRemove: (key) => setDialog({ kind: "remove", key }) })] }), center: locked ? (_jsx(MissingLocaleCanvas, { slug: slug, locale: locale, fallbackLocale: props.fallbackLocale, copyScope: isSystemAdmin ? "system" : "tenant", onCreateBlank: () => setCreating(true), onCopied: () => void onReload() })) : (_jsx(MailEditCenter, { draft: draft, vocabulary: vocabulary, resourcePreviews: resourcePreviews, insert: insert, readOnly: false, fieldErrors: fieldErrors, onChange: update }, editorEpoch)), right: _jsxs(_Fragment, { children: [_jsx(PropertiesSection, { slug: slug, draft: draft, layouts: layouts.rows, onChange: update }), _jsx(ChromeSection, { draft: draft, vocabulary: vocabulary, insert: insert, onChange: update }), _jsx(AttachmentSection, { attachments: attachments, pdfTitles: pdfTitles, canEdit: uploadTemplateId !== null, onAddPdf: () => setPdfOpen(true), onRemove: (key) => setDialog({ kind: "remove", key }) }), _jsx(UsageSection, { slug: slug }), _jsx(VersionsSection, { rowId: row?.id ?? null, currentVersion: row?.version ?? 0, currentStatus: row?.status ?? "draft" })] }) })) : (_jsx(MailPreviewMode, { payload: {
180
+ }, onResetError: setResetError })), testMail.state.kind === "sent" && (_jsx(Banner, { variant: "primary", testId: "mail-test-sent", children: t("documentDesigner.mail.testMail.sent", { subject: testMail.state.subject }) })), testMail.state.kind === "error" && (_jsx(Banner, { variant: "error", testId: "mail-test-error", children: testMail.state.text })), resetError !== null && (_jsx(Banner, { variant: "error", testId: "mail-reset-error", children: resetError })), resourceError !== null && (_jsx(Banner, { variant: "error", testId: "mail-resource-error", children: resourceError })), mode === "edit" ? (_jsx(EditorBody, { left: _jsxs(_Fragment, { children: [_jsx(PlaceholderSection, { validation: railValidation, onInsert: insert.insertPlaceholder }), _jsx(SystemPlaceholderSection, { validation: railValidation, onInsert: insert.insertPlaceholder }), _jsx(ResourceSection, { resources: resources, pdfTitles: pdfTitles, canUpload: uploadTemplateId !== null, filesAvailable: filesAvailable, onUpload: () => setUploadOpen(true), onInsert: insert.insertResource, onRemove: (key) => setDialog({ kind: "remove", key }) })] }), center: locked ? (_jsx(MissingLocaleCanvas, { slug: slug, locale: locale, fallbackLocale: props.fallbackLocale, copyScope: isSystemAdmin ? "system" : "tenant", onCreateBlank: () => setCreating(true), onCopied: () => void onReload() })) : (_jsx(MailEditCenter, { draft: draft, vocabulary: vocabulary, resourcePreviews: resourcePreviews, insert: insert, readOnly: false, fieldErrors: fieldErrors, onChange: update }, editorEpoch)), right: _jsxs(_Fragment, { children: [_jsx(PropertiesSection, { slug: slug, draft: draft, layouts: layouts.rows, onChange: update }), _jsx(ChromeSection, { draft: draft, vocabulary: vocabulary, insert: insert, onChange: update }), _jsx(AttachmentSection, { attachments: attachments, pdfTitles: pdfTitles, canEdit: uploadTemplateId !== null, onAddPdf: () => setPdfOpen(true), onRemove: (key) => setDialog({ kind: "remove", key }) }), _jsx(UsageSection, { slug: slug }), _jsx(VersionsSection, { rowId: row?.id ?? null, currentVersion: row?.version ?? 0, currentStatus: row?.status ?? "draft" })] }) })) : (_jsx(MailPreviewMode, { payload: {
179
181
  slug,
180
182
  kind: "mail",
181
183
  locale,
@@ -184,6 +186,7 @@ export function MailEditorBody(props) {
184
186
  variableSchema,
185
187
  ...(picked !== null && { variables: picked }),
186
188
  }, picked: picked, onPick: setPicked, validation: validation ?? null, slug: slug, locale: locale, fromName: settled.senderName, own: own, origin: origin, controls: controls, testMail: {
189
+ availability: testMail.availability,
187
190
  pending: testMail.pending,
188
191
  onSend: sendTestMail,
189
192
  includeAttachments,
@@ -13,10 +13,11 @@ export declare function SystemPlaceholderSection({ validation, onInsert, idPrefi
13
13
  /** Placeholders the layout fills in, so a template that never uses them is fine. */
14
14
  readonly isLayoutOwned?: (name: string) => boolean;
15
15
  }): ReactNode;
16
- export declare function ResourceSection({ resources, pdfTitles, canUpload, onUpload, onInsert, onRemove, }: {
16
+ export declare function ResourceSection({ resources, pdfTitles, canUpload, filesAvailable, onUpload, onInsert, onRemove, }: {
17
17
  readonly resources: readonly LinkedResourceView[];
18
18
  readonly pdfTitles: ReadonlyMap<string, string>;
19
19
  readonly canUpload: boolean;
20
+ readonly filesAvailable: boolean;
20
21
  readonly onUpload: () => void;
21
22
  readonly onInsert: (key: string) => void;
22
23
  readonly onRemove: (key: string) => void;
@@ -47,10 +47,10 @@ function resourceRow(t, resource, pdfTitles) {
47
47
  };
48
48
  }
49
49
  }
50
- export function ResourceSection({ resources, pdfTitles, canUpload, onUpload, onInsert, onRemove, }) {
50
+ export function ResourceSection({ resources, pdfTitles, canUpload, filesAvailable, onUpload, onInsert, onRemove, }) {
51
51
  const t = useTranslation();
52
52
  const { Button } = usePrimitives();
53
- return (_jsxs(RailSection, { title: t("documentDesigner.mail.resource.title"), testId: "mail-editor-resources", action: _jsx(Button, { variant: "link", disabled: !canUpload, onClick: onUpload, testId: "mail-resource-upload-open", children: t("documentDesigner.mail.resource.upload") }), children: [!canUpload && _jsx(RailHint, { children: t("documentDesigner.mail.resource.saveFirst") }), resources.map((resource) => {
53
+ return (_jsxs(RailSection, { title: t("documentDesigner.mail.resource.title"), testId: "mail-editor-resources", action: _jsx(Button, { variant: "link", disabled: !canUpload || !filesAvailable, onClick: onUpload, testId: "mail-resource-upload-open", children: t("documentDesigner.mail.resource.upload") }), children: [!filesAvailable && _jsx(RailHint, { children: t("documentDesigner.mail.resource.noFilesFeature") }), filesAvailable && !canUpload && (_jsx(RailHint, { children: t("documentDesigner.mail.resource.saveFirst") })), resources.map((resource) => {
54
54
  const row = resourceRow(t, resource, pdfTitles);
55
55
  return (_jsx(RailRow, { testId: "mail-resource-row", symbol: row.symbol, label: row.label, trailing: row.meta, action: _jsx(Button, { variant: "danger-ghost", size: "sm", onClick: () => onRemove(resource.key), testId: `mail-resource-remove-${resource.key}`, children: t("documentDesigner.mail.resource.remove") }), ...(resource.kind === "image" && { onPress: () => onInsert(resource.key) }) }, resource.key));
56
56
  }), _jsx(RailHint, { children: t("documentDesigner.mail.resource.hint") })] }));
@@ -1,7 +1,9 @@
1
1
  import { type ReactNode } from "react";
2
2
  import type { MailPreview, MailValidation, PickedValues } from "./mail-types.ts";
3
3
  import type { OwnAddress } from "./use-own-address.ts";
4
+ import type { TestMailAvailability } from "./use-test-mail.ts";
4
5
  export type TestMailControls = {
6
+ readonly availability: TestMailAvailability;
5
7
  readonly pending: boolean;
6
8
  readonly onSend: () => void;
7
9
  readonly includeAttachments: boolean;
@@ -77,5 +77,7 @@ export function PreviewSide({ preview, validation, slug, locale, own, testMail,
77
77
  const t = useTranslation();
78
78
  const { Button, Field, Input, Text } = usePrimitives();
79
79
  const hasAttachments = preview?.attachments.some((a) => a.kind !== "inline") === true;
80
- return (_jsxs("div", { "data-testid": "mail-preview-side", children: [_jsxs(RailSection, { title: t("documentDesigner.mail.preview.samples"), action: _jsx(RecordPickerLink, { slug: slug, locale: locale, onPick: onPick }), children: [_jsx(SampleValues, { preview: preview, validation: validation, picked: picked, locale: locale }), _jsx(RailHint, { children: t("documentDesigner.mail.preview.samplesHint") })] }), _jsxs(RailSection, { title: t("documentDesigner.mail.testMail.title"), children: [_jsx(TestMailRecipient, { own: own }), _jsxs(RailField, { children: [_jsx(Field, { id: "mail-test-prefix", label: t("documentDesigner.mail.testMail.prefixSubject"), layout: "inline", children: _jsx(Input, { kind: "boolean", id: "mail-test-prefix", name: "mail-test-prefix", value: testMail.prefixSubject, onChange: testMail.onPrefixSubject }) }), hasAttachments ? (_jsx(Field, { id: "mail-test-attachments", label: t("documentDesigner.mail.testMail.attachments"), layout: "inline", children: _jsx(Input, { kind: "boolean", id: "mail-test-attachments", name: "mail-test-attachments", value: testMail.includeAttachments, onChange: testMail.onIncludeAttachments }) })) : (_jsx(Text, { variant: "muted", testId: "mail-test-no-attachments", children: t("documentDesigner.mail.testMail.noAttachments") })), _jsx(Button, { variant: "secondary", width: "full", disabled: testMail.pending, loading: testMail.pending, onClick: testMail.onSend, testId: "mail-test-send", children: t("documentDesigner.mail.testMail.send") })] }), _jsx(RailHint, { children: t("documentDesigner.mail.testMail.hint") })] }), _jsxs(RailSection, { title: t("documentDesigner.mail.preview.checks"), testId: "mail-preview-checks", children: [(preview?.checks ?? []).map((check) => (_jsx(RailRow, { testId: `mail-check-${check.code}`, symbol: check.ok ? "✓" : "!", label: checkText(t, check, validation), bad: !check.ok }, check.code))), _jsx(RailHint, { children: t("documentDesigner.mail.preview.checksHint") })] })] }));
80
+ return (_jsxs("div", { "data-testid": "mail-preview-side", children: [_jsxs(RailSection, { title: t("documentDesigner.mail.preview.samples"), action: _jsx(RecordPickerLink, { slug: slug, locale: locale, onPick: onPick }), children: [_jsx(SampleValues, { preview: preview, validation: validation, picked: picked, locale: locale }), _jsx(RailHint, { children: t("documentDesigner.mail.preview.samplesHint") })] }), _jsxs(RailSection, { title: t("documentDesigner.mail.testMail.title"), children: [_jsx(TestMailRecipient, { own: own }), _jsxs(RailField, { children: [_jsx(Field, { id: "mail-test-prefix", label: t("documentDesigner.mail.testMail.prefixSubject"), layout: "inline", children: _jsx(Input, { kind: "boolean", id: "mail-test-prefix", name: "mail-test-prefix", value: testMail.prefixSubject, onChange: testMail.onPrefixSubject }) }), hasAttachments ? (_jsx(Field, { id: "mail-test-attachments", label: t("documentDesigner.mail.testMail.attachments"), layout: "inline", children: _jsx(Input, { kind: "boolean", id: "mail-test-attachments", name: "mail-test-attachments", value: testMail.includeAttachments, onChange: testMail.onIncludeAttachments }) })) : (_jsx(Text, { variant: "muted", testId: "mail-test-no-attachments", children: t("documentDesigner.mail.testMail.noAttachments") })), _jsx(Button, { variant: "secondary", width: "full", disabled: testMail.pending || !testMail.availability.canSend, loading: testMail.pending, onClick: testMail.onSend, testId: "mail-test-send", children: t("documentDesigner.mail.testMail.send") })] }), testMail.availability.reason !== null && (_jsx(RailHint, { children: t(testMail.availability.reason === "no_channel"
81
+ ? "documentDesigner.mail.testMail.unavailable.noChannel"
82
+ : "documentDesigner.mail.testMail.unavailable.noAddress") })), _jsx(RailHint, { children: t("documentDesigner.mail.testMail.hint") })] }), _jsxs(RailSection, { title: t("documentDesigner.mail.preview.checks"), testId: "mail-preview-checks", children: [(preview?.checks ?? []).map((check) => (_jsx(RailRow, { testId: `mail-check-${check.code}`, symbol: check.ok ? "✓" : "!", label: checkText(t, check, validation), bad: !check.ok }, check.code))), _jsx(RailHint, { children: t("documentDesigner.mail.preview.checksHint") })] })] }));
81
83
  }
@@ -23,7 +23,7 @@ function HeadRow({ label, children, }) {
23
23
  function MailFrame({ preview, format, width, fromName, recipient, }) {
24
24
  const t = useTranslation();
25
25
  const attachments = preview.attachments.flatMap((a) => attachmentText(t, a) ?? []);
26
- return (_jsxs("div", { className: "mx-auto flex w-full max-w-[680px] flex-col border bg-card shadow-md", "data-testid": "mail-preview-frame", children: [_jsxs("div", { className: "grid grid-cols-[80px_minmax(0,1fr)] gap-y-1 border-b px-5 py-3.5 text-sm", children: [_jsx(HeadRow, { label: t("documentDesigner.mail.preview.from"), children: _jsx("span", { "data-testid": "mail-preview-from", children: fromName === "" ? t("documentDesigner.mail.props.senderDefault") : fromName }) }), recipient !== null && (_jsx(HeadRow, { label: t("documentDesigner.mail.preview.to"), children: _jsx("span", { "data-testid": "mail-preview-to", children: recipient }) })), _jsx(HeadRow, { label: t("documentDesigner.mail.meta.subject"), children: _jsx("span", { className: "font-medium", "data-testid": "mail-preview-subject", children: preview.subject ?? "" }) }), attachments.length > 0 && (_jsx(HeadRow, { label: t("documentDesigner.mail.preview.attachments"), children: _jsx("span", { className: "flex flex-col items-start gap-1", "data-testid": "mail-preview-attachments", children: attachments.map((line) => (_jsx("span", { className: "rounded border bg-muted px-2 py-0.5 text-xs", children: line }, line))) }) }))] }), format === "html" ? (_jsx("iframe", { title: t("documentDesigner.mail.preview.frameTitle"), sandbox: "", srcDoc: preview.displayHtml ?? preview.html, className: `h-[560px] border-0 bg-background ${width === "mobile" ? "w-[375px] self-center" : "w-full"}`, "data-testid": "mail-preview-iframe" })) : (_jsx("pre", { className: "h-[560px] overflow-auto whitespace-pre-wrap p-4 font-mono text-sm", "data-testid": "mail-preview-text", children: preview.text ?? "" }))] }));
26
+ return (_jsxs("div", { className: "mx-auto flex min-h-0 w-full max-w-[680px] flex-1 flex-col border bg-card shadow-md", "data-testid": "mail-preview-frame", children: [_jsxs("div", { className: "grid grid-cols-[80px_minmax(0,1fr)] gap-y-1 border-b px-5 py-3.5 text-sm", children: [_jsx(HeadRow, { label: t("documentDesigner.mail.preview.from"), children: _jsx("span", { "data-testid": "mail-preview-from", children: fromName === "" ? t("documentDesigner.mail.props.senderDefault") : fromName }) }), recipient !== null && (_jsx(HeadRow, { label: t("documentDesigner.mail.preview.to"), children: _jsx("span", { "data-testid": "mail-preview-to", children: recipient }) })), _jsx(HeadRow, { label: t("documentDesigner.mail.meta.subject"), children: _jsx("span", { className: "font-medium", "data-testid": "mail-preview-subject", children: preview.subject ?? "" }) }), attachments.length > 0 && (_jsx(HeadRow, { label: t("documentDesigner.mail.preview.attachments"), children: _jsx("span", { className: "flex flex-col items-start gap-1", "data-testid": "mail-preview-attachments", children: attachments.map((line) => (_jsx("span", { className: "rounded border bg-muted px-2 py-0.5 text-xs", children: line }, line))) }) }))] }), format === "html" ? (_jsx("iframe", { title: t("documentDesigner.mail.preview.frameTitle"), sandbox: "", srcDoc: preview.displayHtml ?? preview.html, className: `min-h-[420px] flex-1 border-0 bg-background ${width === "mobile" ? "w-[375px] self-center" : "w-full"}`, "data-testid": "mail-preview-iframe" })) : (_jsx("pre", { className: "min-h-[420px] flex-1 overflow-auto whitespace-pre-wrap p-4 font-mono text-sm", "data-testid": "mail-preview-text", children: preview.text ?? "" }))] }));
27
27
  }
28
28
  // The preview mail goes to the example recipient of the sample values, not to the test-mail address.
29
29
  function recipientOf(preview, own) {
@@ -119,4 +119,8 @@ export type UpsertResponse = {
119
119
  export type SendTestResponse = {
120
120
  readonly subject: string;
121
121
  };
122
+ export type SendTestStatus = {
123
+ readonly canSend: boolean;
124
+ readonly reason: "no_channel" | "no_address" | null;
125
+ };
122
126
  export type PickedValues = Readonly<Record<string, unknown>>;
@@ -12,6 +12,7 @@ export type PageCanvasProps = {
12
12
  readonly systemVariables: readonly string[];
13
13
  readonly systemLabels: Readonly<Record<string, string>>;
14
14
  readonly variableNames: readonly string[];
15
+ readonly imageVariables: readonly string[];
15
16
  readonly resourcePreviews: Readonly<Record<string, DocumentResourcePreview>>;
16
17
  readonly onEditorReady: (handle: DocumentEditorHandle | null) => void;
17
18
  readonly resourceSrc?: (key: string) => string | undefined;
@@ -44,5 +44,5 @@ export function PageCanvas(props) {
44
44
  }
45
45
  }, [meta.margins, hasFooter]);
46
46
  const renderPaper = (editable) => (_jsxs("div", { className: "flex w-full flex-col items-center gap-2", children: [_jsxs("div", { "data-testid": "document-designer-page", className: cn("flex min-h-[1000px] w-full flex-col bg-card shadow-md", PAGE_WIDTH_CLASSES[`${meta.pageFormat}-${meta.orientation}`]), children: [showLetterhead && props.regions !== null && (_jsx(HeaderBand, { html: props.regions.header, systemLabels: props.systemLabels, resourceSrc: props.resourceSrc, caption: fromLayout })), _jsxs("div", { ref: applyMargins, className: cn("flex min-h-0 flex-1 flex-col pr-(--page-margin-right) pb-(--page-margin-bottom) pl-(--page-margin-left)", !showLetterhead && "pt-(--page-margin-top)"), children: [_jsxs("div", { className: "relative my-2 min-h-0 flex-1 border border-dashed border-primary/40 p-2 pt-5", "data-testid": "document-designer-content-area", children: [_jsx("span", { className: "absolute left-2 top-0.5 text-[10px] uppercase tracking-wide text-primary", children: t("documentDesigner.editor.region.content") }), editable] }), showRegions && props.regions !== null && (_jsxs(_Fragment, { children: [_jsx(RegionBlock, { html: props.regions.signature, systemLabels: props.systemLabels, resourceSrc: props.resourceSrc, caption: regionCaption("signature") }), _jsx(RegionBlock, { html: props.regions.footer, systemLabels: props.systemLabels, resourceSrc: props.resourceSrc, caption: regionCaption("footer") })] }))] })] }), _jsx("p", { className: "text-xs text-muted-foreground", children: t("documentDesigner.canvas.caption", { format: meta.pageFormat }) })] }));
47
- return (_jsx("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col", "data-testid": "pdf-editor-center", children: _jsx(DocumentContentEditor, { id: "document-designer-content", value: props.content, onChange: props.onContentChange, variables: props.variableNames, readOnly: false, blocks: PDF_DOCUMENT_BLOCKS, variableLabels: props.variableLabels, systemVariables: props.systemVariables, resourcePreviews: props.resourcePreviews, chrome: "canvas", renderPaper: renderPaper, onEditorReady: props.onEditorReady }) }));
47
+ return (_jsx("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col", "data-testid": "pdf-editor-center", children: _jsx(DocumentContentEditor, { id: "document-designer-content", value: props.content, onChange: props.onContentChange, variables: props.variableNames, imageVariables: props.imageVariables, readOnly: false, blocks: PDF_DOCUMENT_BLOCKS, variableLabels: props.variableLabels, systemVariables: props.systemVariables, resourcePreviews: props.resourcePreviews, chrome: "canvas", renderPaper: renderPaper, onEditorReady: props.onEditorReady }) }));
48
48
  }
@@ -1,8 +1,9 @@
1
1
  import type { ReactNode } from "react";
2
2
  export declare function visibleSystemPlaceholders(names: readonly string[]): readonly string[];
3
- export declare function PdfResourceSection({ resourceKeys, canUpload, layoutName, onInsert, onUpload, }: {
3
+ export declare function PdfResourceSection({ resourceKeys, canUpload, filesAvailable, layoutName, onInsert, onUpload, }: {
4
4
  readonly resourceKeys: readonly string[];
5
5
  readonly canUpload: boolean;
6
+ readonly filesAvailable: boolean;
6
7
  readonly layoutName: string;
7
8
  readonly onInsert: (key: string) => void;
8
9
  readonly onUpload: (file: File) => Promise<void>;
@@ -9,8 +9,8 @@ export function visibleSystemPlaceholders(names) {
9
9
  return names.filter((name) => !HIDDEN_SYSTEM_PLACEHOLDERS.has(name));
10
10
  }
11
11
  const IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
12
- export function PdfResourceSection({ resourceKeys, canUpload, layoutName, onInsert, onUpload, }) {
12
+ export function PdfResourceSection({ resourceKeys, canUpload, filesAvailable, layoutName, onInsert, onUpload, }) {
13
13
  const t = useTranslation();
14
14
  const { Button } = usePrimitives();
15
- return (_jsxs(RailSection, { title: t("documentDesigner.resources.title"), testId: "pdf-editor-resources", children: [resourceKeys.length === 0 && _jsx(RailRow, { label: t("documentDesigner.resources.empty") }), resourceKeys.map((key) => (_jsx(RailRow, { testId: `pdf-resource-${key}`, symbol: "\u25A3", label: key, onPress: () => onInsert(key) }, key))), _jsx(RailField, { children: canUpload ? (_jsx(UploadZone, { multiple: false, accept: IMAGE_TYPES, title: t("documentDesigner.resources.upload"), onUpload: onUpload, testId: "document-designer-upload" })) : (_jsx(Button, { variant: "secondary", size: "sm", disabled: true, children: t("documentDesigner.resources.upload") })) }), !canUpload && _jsx(RailHint, { children: t("documentDesigner.resources.saveFirst") }), _jsx(RailHint, { children: t("documentDesigner.placeholders.layoutHint", { layout: layoutName }) })] }));
15
+ return (_jsxs(RailSection, { title: t("documentDesigner.resources.title"), testId: "pdf-editor-resources", children: [resourceKeys.length === 0 && _jsx(RailRow, { label: t("documentDesigner.resources.empty") }), resourceKeys.map((key) => (_jsx(RailRow, { testId: `pdf-resource-${key}`, symbol: "\u25A3", label: key, onPress: () => onInsert(key) }, key))), _jsx(RailField, { children: canUpload && filesAvailable ? (_jsx(UploadZone, { multiple: false, accept: IMAGE_TYPES, title: t("documentDesigner.resources.upload"), onUpload: onUpload, testId: "document-designer-upload" })) : (_jsx(Button, { variant: "secondary", size: "sm", disabled: true, children: t("documentDesigner.resources.upload") })) }), !filesAvailable && _jsx(RailHint, { children: t("documentDesigner.resources.noFilesFeature") }), filesAvailable && !canUpload && (_jsx(RailHint, { children: t("documentDesigner.resources.saveFirst") })), _jsx(RailHint, { children: t("documentDesigner.placeholders.layoutHint", { layout: layoutName }) })] }));
16
16
  }
@@ -2,7 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  // @runtime client
3
3
  import { TemplateResolverHandlers, TemplateResolverQueries, } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
4
4
  import { useDispatcher, usePrimitives, useQuery, useTranslation, useUserRoles, } from "@cosmicdrift/kumiko-renderer";
5
- import { ErrorState, LoadingState, uploadFileRef } from "@cosmicdrift/kumiko-renderer/web";
5
+ import { ErrorState, LoadingState, PdfPreview, uploadFileRef, } from "@cosmicdrift/kumiko-renderer/web";
6
6
  import { RendererPuppeteerClientQueries } from "@cosmicdrift/kumiko-renderer-puppeteer-client/qualified-names";
7
7
  import { useEffect, useMemo, useRef, useState } from "react";
8
8
  import { BRANDING_QUERY_QN } from "../constants.js";
@@ -17,16 +17,41 @@ import { PageCanvas } from "./page-canvas.js";
17
17
  import { pdfFooterMessage } from "./pdf-footer.js";
18
18
  import { PdfResourceSection, visibleSystemPlaceholders } from "./pdf-left-rail.js";
19
19
  import { EMPTY_PDF_META, parsePdfMeta, serializePdfMeta } from "./pdf-meta.js";
20
- import { PdfPreview } from "./pdf-preview.js";
21
20
  import { SettingsPanel } from "./settings-panel.js";
22
21
  import { draftBesideActiveSentence } from "./status-label.js";
23
22
  import { localesOf, parseJsonObject, pickRow, resolveLocale } from "./template-rows.js";
24
23
  import { DEFAULT_LAYOUT_SLUG, DOCUMENT_PDF_KIND, PDF_LAYOUT_KIND, } from "./types.js";
24
+ import { useFilesCapability } from "./use-files-capability.js";
25
25
  import { useInsertTargets } from "./use-insert-targets.js";
26
26
  import { useOverviewRows } from "./use-overview-rows.js";
27
27
  import { templateTitle } from "./used-by-label.js";
28
28
  const SETTLE_MS = 500;
29
29
  const NO_VARIABLES = {};
30
+ // Only the label of an images variable is needed, for the Images block and its picker.
31
+ function imageLabelSource(variables) {
32
+ const result = {};
33
+ for (const [name, info] of Object.entries(variables)) {
34
+ if (info.type === "images" && info.label !== undefined) {
35
+ result[name] = { type: "text", label: info.label };
36
+ }
37
+ }
38
+ return result;
39
+ }
40
+ function textVariablesOf(variables) {
41
+ const result = {};
42
+ for (const [name, info] of Object.entries(variables)) {
43
+ if (info.type === "images")
44
+ continue;
45
+ const { item, ...rest } = info;
46
+ const columns = Object.entries(item ?? {}).flatMap(([column, def]) => def.type === "images" ? [] : [[column, { ...def, type: def.type }]]);
47
+ result[name] = {
48
+ ...rest,
49
+ type: info.type,
50
+ ...(item !== undefined && { item: Object.fromEntries(columns) }),
51
+ };
52
+ }
53
+ return result;
54
+ }
30
55
  // Opening the preview prints the page without a click; a failed print is not retried for the same draft.
31
56
  function RunOnMount({ run }) {
32
57
  useEffect(() => {
@@ -44,6 +69,7 @@ export function PdfTemplateEditor({ slug, locale: requestedLocale, onLocaleChang
44
69
  const dispatcher = useDispatcher();
45
70
  const { Button, Banner, Dialog } = usePrimitives();
46
71
  const insert = useInsertTargets();
72
+ const filesAvailable = useFilesCapability();
47
73
  const overview = useOverviewRows(DOCUMENT_PDF_KIND);
48
74
  const roles = useUserRoles();
49
75
  const scope = roles?.includes("SystemAdmin") === true ? "system" : "tenant";
@@ -112,10 +138,17 @@ export function PdfTemplateEditor({ slug, locale: requestedLocale, onLocaleChang
112
138
  const unavailableMessage = statusQuery.data?.available === false ? (statusQuery.data.message ?? "") : "";
113
139
  const showOrigin = scope === "system";
114
140
  const overridesQuery = useQuery(TemplateResolverQueries.tenantOverrides, { slug, kind: DOCUMENT_PDF_KIND, locale }, { enabled: showOrigin && detail !== null });
115
- const variables = validation?.variables ?? NO_VARIABLES;
141
+ const allVariables = validation?.variables ?? NO_VARIABLES;
142
+ // Photos are record data, never an inline placeholder: images variables only feed the Images block.
143
+ const variables = useMemo(() => textVariablesOf(allVariables), [allVariables]);
144
+ const imageVariables = useMemo(() => Object.keys(allVariables).filter((name) => allVariables[name]?.type === "images"), [allVariables]);
116
145
  const systemVariables = useMemo(() => visibleSystemPlaceholders(validation?.systemPlaceholders ?? []), [validation?.systemPlaceholders]);
117
146
  const systemLabels = useMemo(() => systemLabelMap(t, systemVariables), [t, systemVariables]);
118
- const variableLabels = useMemo(() => ({ ...systemLabels, ...variableLabelMap(t, variables) }), [systemLabels, variables, t]);
147
+ const variableLabels = useMemo(() => ({
148
+ ...systemLabels,
149
+ ...variableLabelMap(t, variables),
150
+ ...variableLabelMap(t, imageLabelSource(allVariables)),
151
+ }), [systemLabels, variables, allVariables, t]);
119
152
  const variableNames = useMemo(() => Object.keys(variables), [variables]);
120
153
  const linkedResources = detail?.linkedResources ?? null;
121
154
  // The editor shows resource images from the stored copies; no extra request is made here.
@@ -256,6 +289,7 @@ export function PdfTemplateEditor({ slug, locale: requestedLocale, onLocaleChang
256
289
  ? null
257
290
  : {
258
291
  ...validation,
292
+ variables,
259
293
  systemPlaceholders: visibleSystemPlaceholders(validation.systemPlaceholders),
260
294
  };
261
295
  const layoutFills = (name) => layout !== null &&
@@ -302,7 +336,7 @@ export function PdfTemplateEditor({ slug, locale: requestedLocale, onLocaleChang
302
336
  onPublish: () => void publish(),
303
337
  }, children: _jsx(EditorModeSwitch, { testIdPrefix: "pdf", mode: mode, onMode: setMode }) }), _jsxs(EditorInfoBar, { tone: pdfUnavailableNote ? "warn" : detail.scope === "tenant" ? "active" : "muted", testId: "pdf-origin-bar", origin: originOf(detail.scope === "system", systemVersion !== null), controls: _jsxs(_Fragment, { children: [_jsx(EditorLocaleSwitch, { testIdPrefix: "pdf", locales: locales.length > 0 ? locales : [locale], locale: locale, onLocale: requestLocale }), mode === "preview" && (_jsx(Button, { variant: "secondary", disabled: !pdfAvailable || busy !== null, loading: busy === "pdf", onClick: () => void generatePdf(), testId: "pdf-generate-header", children: t("documentDesigner.actions.generatePdf") }))] }), overlays: _jsx(OverridesDrawer, { open: overridesOpen, onOpenChange: setOverridesOpen, slug: slug, locale: locale, overrides: overridesQuery.data }), links: showOverrideLinks && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "link", onClick: () => void exportBundle(), children: t("documentDesigner.originBar.export") }), _jsx(Button, { variant: "link", onClick: () => setOverridesOpen(true), children: t("documentDesigner.originBar.showOverrides") })] })), children: [originText, besideActiveSentence !== null && ` ${besideActiveSentence}`, pdfUnavailableNote && (_jsxs(_Fragment, { children: [" ", _jsx("span", { "data-testid": "pdf-unavailable", children: unavailableMessage === ""
304
338
  ? t("documentDesigner.pdfUnavailableNoReason")
305
- : t("documentDesigner.pdfUnavailable", { message: unavailableMessage }) })] }))] }), _jsx(EditorBody, { left: mode === "edit" ? (_jsxs(_Fragment, { children: [_jsx(PlaceholderSection, { validation: placeholderSource, onInsert: insert.insertPlaceholder, idPrefix: "pdf" }), _jsx(SystemPlaceholderSection, { validation: placeholderSource, onInsert: insert.insertPlaceholder, idPrefix: "pdf", isLayoutOwned: layoutFills }), _jsx(PdfResourceSection, { resourceKeys: resourceKeys, canUpload: detail.scope === scope, layoutName: layoutName, onInsert: insert.insertResource, onUpload: uploadResource })] })) : undefined, center: mode === "edit" ? (_jsx("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col", onFocusCapture: insert.markFocused("body"), children: _jsx(PageCanvas, { meta: current.draft.meta, content: current.draft.content, onContentChange: (content) => updateDraft({ ...current.draft, content }), regions: layout?.regions ?? null, layoutName: layoutName, resourceSrc: layoutResourceSrc, variableLabels: variableLabels, systemVariables: systemVariables, systemLabels: systemLabels, variableNames: variableNames, resourcePreviews: resourcePreviews, onEditorReady: insert.register("body") }) })) : (_jsx("div", { className: "min-h-0 flex-1 overflow-auto", children: generated !== null && generatedFresh ? (_jsx(PdfPreview, { pdfBase64: generated.result.pdfBase64 })) : (_jsxs("div", { className: "flex flex-col items-start gap-3 p-6", "data-testid": "document-designer-preview-stale", children: [pdfAvailable && busy === null && _jsx(RunOnMount, { run: printOnce }), _jsx("p", { className: "text-sm text-muted-foreground", children: busy === "pdf"
339
+ : t("documentDesigner.pdfUnavailable", { message: unavailableMessage }) })] }))] }), _jsx(EditorBody, { left: mode === "edit" ? (_jsxs(_Fragment, { children: [_jsx(PlaceholderSection, { validation: placeholderSource, onInsert: insert.insertPlaceholder, idPrefix: "pdf" }), _jsx(SystemPlaceholderSection, { validation: placeholderSource, onInsert: insert.insertPlaceholder, idPrefix: "pdf", isLayoutOwned: layoutFills }), _jsx(PdfResourceSection, { resourceKeys: resourceKeys, canUpload: detail.scope === scope, filesAvailable: filesAvailable, layoutName: layoutName, onInsert: insert.insertResource, onUpload: uploadResource })] })) : undefined, center: mode === "edit" ? (_jsx("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col", onFocusCapture: insert.markFocused("body"), children: _jsx(PageCanvas, { meta: current.draft.meta, content: current.draft.content, onContentChange: (content) => updateDraft({ ...current.draft, content }), regions: layout?.regions ?? null, layoutName: layoutName, resourceSrc: layoutResourceSrc, variableLabels: variableLabels, systemVariables: systemVariables, systemLabels: systemLabels, variableNames: variableNames, imageVariables: imageVariables, resourcePreviews: resourcePreviews, onEditorReady: insert.register("body") }) })) : (_jsx("div", { className: "min-h-0 flex-1 overflow-auto", children: generated !== null && generatedFresh ? (_jsx(PdfPreview, { source: { base64: generated.result.pdfBase64 }, testId: "document-designer-pdf-preview" })) : (_jsxs("div", { className: "flex flex-col items-start gap-3 p-6", "data-testid": "document-designer-preview-stale", children: [pdfAvailable && busy === null && _jsx(RunOnMount, { run: printOnce }), _jsx("p", { className: "text-sm text-muted-foreground", children: busy === "pdf"
306
340
  ? t("documentDesigner.preview.loading")
307
341
  : t("documentDesigner.preview.stale") }), _jsx(Button, { variant: "secondary", disabled: !pdfAvailable || busy !== null, onClick: () => void generatePdf(), children: t("documentDesigner.actions.generatePdf") })] })) })), right: _jsx(SettingsPanel, { meta: current.draft.meta, onMetaChange: (meta) => updateDraft({ ...current.draft, meta }), layouts: layouts.rows, locale: locale, version: { id: detail.id, version: detail.version, status: detail.status }, vocabulary: vocabulary, insert: insert, draftKey: `${current.key}:${draftEpoch}` }) }), _jsx(EditorFooter, { testIdPrefix: "pdf-editor", message: footer, saveError: actionError, modifiedAt: detail.modifiedAt ?? null, dirty: dirty, busy: busy === "save", canSave: busy === null && validation?.canSave !== false, canPublish: busy === null && canPublish, onDiscard: () => resetDraft(detail), onSave: () => void save(), onPublish: () => void publish() }), _jsx(Dialog, { open: pendingLocale !== null, onOpenChange: (open) => {
308
342
  if (!open)
@@ -39,7 +39,7 @@ export type ServedVersionView = {
39
39
  readonly version: number;
40
40
  readonly since: string;
41
41
  };
42
- export type VariableType = "text" | "link" | "money" | "date" | "percent" | "list";
42
+ export type VariableType = "text" | "link" | "money" | "date" | "percent" | "list" | "images";
43
43
  export type VariableInfo = {
44
44
  readonly type: VariableType;
45
45
  readonly label?: string;
@@ -0,0 +1 @@
1
+ export declare function useFilesCapability(): boolean;
@@ -0,0 +1,8 @@
1
+ // @runtime client
2
+ import { useAppFeatures } from "@cosmicdrift/kumiko-renderer";
3
+ import { FILES_FEATURE_NAME } from "../constants.js";
4
+ // The app schema lists every mounted feature, screenless ones included, so this is the server's
5
+ // answer: without the files feature there is no upload route.
6
+ export function useFilesCapability() {
7
+ return useAppFeatures().some((feature) => feature.featureName === FILES_FEATURE_NAME);
8
+ }
@@ -1,3 +1,4 @@
1
+ import type { SendTestStatus } from "./mail-types.ts";
1
2
  export type TestMailState = {
2
3
  readonly kind: "idle";
3
4
  } | {
@@ -7,7 +8,12 @@ export type TestMailState = {
7
8
  readonly kind: "error";
8
9
  readonly text: string;
9
10
  };
11
+ export type TestMailAvailability = {
12
+ readonly canSend: boolean;
13
+ readonly reason: SendTestStatus["reason"];
14
+ };
10
15
  export type TestMail = {
16
+ readonly availability: TestMailAvailability;
11
17
  readonly state: TestMailState;
12
18
  readonly pending: boolean;
13
19
  readonly send: (payload: Readonly<Record<string, unknown>>) => Promise<void>;
@@ -1,12 +1,17 @@
1
1
  // @runtime client
2
- import { TemplateResolverHandlers } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
3
- import { dispatcherErrorText, useMutation, useTranslation } from "@cosmicdrift/kumiko-renderer";
2
+ import { TemplateResolverHandlers, TemplateResolverQueries, } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
3
+ import { dispatcherErrorText, useMutation, useQuery, useTranslation, } from "@cosmicdrift/kumiko-renderer";
4
4
  import { useState } from "react";
5
5
  const TOO_MANY_REQUESTS = 429;
6
6
  // The recipient is always the caller; the server never returns the address.
7
7
  export function useTestMail() {
8
8
  const t = useTranslation();
9
9
  const sendTest = useMutation(TemplateResolverHandlers.sendTest);
10
+ const status = useQuery(TemplateResolverQueries.sendTestStatus, {});
11
+ const availability = {
12
+ canSend: status.data?.canSend === true,
13
+ reason: status.data?.reason ?? null,
14
+ };
10
15
  const [state, setState] = useState({ kind: "idle" });
11
16
  const send = async (payload) => {
12
17
  setState({ kind: "idle" });
@@ -23,5 +28,5 @@ export function useTestMail() {
23
28
  });
24
29
  }
25
30
  };
26
- return { state, pending: sendTest.pending, send };
31
+ return { availability, state, pending: sendTest.pending, send };
27
32
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-document-designer",
3
- "version": "0.377.0",
3
+ "version": "0.378.0",
4
4
  "description": "Document designer for Kumiko: template overview, layout editor (mail layout, PDF letterhead) and the PDF template editor.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,15 +16,15 @@
16
16
  "./web": {
17
17
  "types": "./dist/web/index.d.ts",
18
18
  "default": "./dist/web/index.js"
19
- }
19
+ },
20
+ "./web/styles.css": "./src/web/styles.css"
20
21
  },
21
22
  "dependencies": {
22
- "@cosmicdrift/kumiko-bundled-features": "0.377.0",
23
- "@cosmicdrift/kumiko-framework": "0.377.0",
24
- "@cosmicdrift/kumiko-renderer": "0.377.0",
25
- "@cosmicdrift/kumiko-renderer-puppeteer-client": "0.377.0",
26
- "@cosmicdrift/kumiko-types": "0.377.0",
27
- "pdfjs-dist": "6.3.289",
23
+ "@cosmicdrift/kumiko-bundled-features": "0.378.0",
24
+ "@cosmicdrift/kumiko-framework": "0.378.0",
25
+ "@cosmicdrift/kumiko-renderer": "0.378.0",
26
+ "@cosmicdrift/kumiko-renderer-puppeteer-client": "0.378.0",
27
+ "@cosmicdrift/kumiko-types": "0.378.0",
28
28
  "react": "^19.2.6"
29
29
  },
30
30
  "devDependencies": {
@@ -51,11 +51,13 @@
51
51
  "./web": {
52
52
  "types": "./dist/web/index.d.ts",
53
53
  "default": "./dist/web/index.js"
54
- }
54
+ },
55
+ "./web/styles.css": "./src/web/styles.css"
55
56
  }
56
57
  },
57
58
  "files": [
58
59
  "dist",
60
+ "src/web/styles.css",
59
61
  "src/changes.json",
60
62
  "README.md",
61
63
  "LICENSE"
package/src/changes.json CHANGED
@@ -1,4 +1,29 @@
1
1
  [
2
+ {
3
+ "version": "0.378.0",
4
+ "type": "fix",
5
+ "title": "Mail preview fills the editor column",
6
+ "detail": "The preview frame (HTML and plain text) had a fixed height of 560px, so long mails were cut off mid-sentence with an empty grey area below. The frame now grows with the center column (at least 420px) and scrolls inside."
7
+ },
8
+ {
9
+ "version": "0.378.0",
10
+ "type": "breaking",
11
+ "title": "Designer screens are SystemAdmin-only unless the app passes access",
12
+ "detail": "The templates screen, layout, PDF and mail editors are visible to SystemAdmin only by default. createDocumentDesignerFeature takes an optional access rule whose roles are added.",
13
+ "migration": "Pass createDocumentDesignerFeature({ access: templateEditAccess(tenantEditable) }) with the same entries given to the template resolver when TenantAdmins edit templates."
14
+ },
15
+ {
16
+ "version": "0.378.0",
17
+ "type": "improvement",
18
+ "title": "Stylesheet export for the designer",
19
+ "detail": "\"@cosmicdrift/kumiko-document-designer/web/styles.css\" registers the designer's own Tailwind sources. Apps that embed the designer import it after the renderer stylesheet."
20
+ },
21
+ {
22
+ "version": "0.378.0",
23
+ "type": "fix",
24
+ "title": "Upload and test mail follow the server's capabilities",
25
+ "detail": "Upload is disabled with a hint when the files feature is not mounted, and the test mail button is disabled with a hint when the server has no email channel or no address for the user."
26
+ },
2
27
  {
3
28
  "version": "0.372.0",
4
29
  "type": "fix",
@@ -0,0 +1,12 @@
1
+ /* Tailwind v4 resolves relative @source paths against the REALPATH of the
2
+ imported CSS file, so only the package itself knows where its sources live
3
+ in every install layout (workspace, node_modules, isolated or pnpm stores);
4
+ a path from the renderer's stylesheet would point at a sibling that is not
5
+ there. `./` is this package's src/web, `../../dist` its compiled twin
6
+ (class names survive as string literals in the .js). An app imports this
7
+ file after `@cosmicdrift/kumiko-renderer/web/styles.css`. */
8
+ @source "./**/*.{ts,tsx}";
9
+ @source "../../dist/**/*.js";
10
+ /* `tsc --build` emits compiled tests into dist in the monorepo; published dist has none. */
11
+ @source not "./**/__tests__/**";
12
+ @source not "../../dist/**/__tests__/**";
@@ -1,4 +0,0 @@
1
- import { type ReactNode } from "react";
2
- export declare function PdfPreview({ pdfBase64 }: {
3
- readonly pdfBase64: string;
4
- }): ReactNode;
@@ -1,112 +0,0 @@
1
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- // @runtime client
3
- /// <reference path="./pdfjs-worker.d.ts" />
4
- import { useTranslation } from "@cosmicdrift/kumiko-renderer";
5
- import { Suspense, use, useCallback, useMemo } from "react";
6
- // pdf.js runs in the main thread ("fake worker"): the worker module is bundled
7
- // with the app and handed over through globalThis.pdfjsWorker, so no worker or
8
- // CDN URL is ever fetched (self-hosting and CSP stay intact).
9
- const RENDER_SCALE = 96 / 72;
10
- let cachedKey = null;
11
- let cachedLoad = null;
12
- function decodeBase64(base64) {
13
- const binary = atob(base64);
14
- const bytes = new Uint8Array(binary.length);
15
- for (let index = 0; index < binary.length; index++)
16
- bytes[index] = binary.charCodeAt(index);
17
- return bytes;
18
- }
19
- async function loadDocument(pdfBase64) {
20
- try {
21
- const [pdfjs, worker] = await Promise.all([
22
- import("pdfjs-dist"),
23
- import("pdfjs-dist/build/pdf.worker.min.mjs"),
24
- ]);
25
- Object.assign(globalThis, { pdfjsWorker: worker });
26
- const loadingTask = pdfjs.getDocument({ data: decodeBase64(pdfBase64) });
27
- return { ok: true, document: await loadingTask.promise, loadingTask };
28
- }
29
- catch {
30
- return { ok: false };
31
- }
32
- }
33
- // The promise must be stable across renders for use(); only the latest PDF is kept.
34
- function documentFor(pdfBase64) {
35
- if (cachedKey !== pdfBase64 || cachedLoad === null) {
36
- const previous = cachedLoad;
37
- cachedKey = pdfBase64;
38
- cachedLoad = loadDocument(pdfBase64);
39
- void previous?.then((loaded) => {
40
- if (loaded.ok)
41
- void loaded.loadingTask.destroy();
42
- });
43
- }
44
- return cachedLoad;
45
- }
46
- function PageView({ document, pageNumber, }) {
47
- const t = useTranslation();
48
- const page = use(pageFor(document, pageNumber));
49
- // Stable identity: the mount callbacks below depend on it, and a new viewport per render re-mounts canvas and text layer.
50
- const viewport = useMemo(() => page.getViewport({ scale: RENDER_SCALE }), [page]);
51
- const mountCanvas = useCallback((canvas) => {
52
- if (canvas === null)
53
- return;
54
- const task = page.render({ canvas, viewport });
55
- task.promise.catch(() => undefined);
56
- return () => task.cancel();
57
- }, [page, viewport]);
58
- const mountTextLayer = useCallback((container) => {
59
- if (container === null)
60
- return;
61
- container.style.setProperty("--total-scale-factor", String(RENDER_SCALE));
62
- container.style.setProperty("--scale-factor", String(RENDER_SCALE));
63
- let layer = null;
64
- let cancelled = false;
65
- void import("pdfjs-dist").then(({ TextLayer }) => {
66
- // skip: cleanup ran before pdfjs finished loading
67
- if (cancelled)
68
- return undefined;
69
- // TextLayer.cancel() keeps rendered spans; a re-mount must not append a second layer.
70
- container.replaceChildren();
71
- const textLayer = new TextLayer({
72
- textContentSource: page.streamTextContent(),
73
- container,
74
- viewport,
75
- });
76
- layer = textLayer;
77
- return textLayer.render().catch(() => undefined);
78
- });
79
- return () => {
80
- cancelled = true;
81
- layer?.cancel();
82
- };
83
- }, [page, viewport]);
84
- return (_jsxs("figure", { className: "m-0 flex flex-col items-center gap-2", "data-testid": `pdf-page-${pageNumber}`, children: [_jsxs("div", { className: "relative w-fit bg-card shadow-md", children: [_jsx("canvas", { ref: mountCanvas, width: viewport.width, height: viewport.height }), _jsx("div", { ref: mountTextLayer, className: "absolute inset-0 overflow-hidden leading-none [&_span]:absolute [&_span]:origin-top-left [&_span]:cursor-text [&_span]:whitespace-pre [&_span]:text-transparent [&_span::selection]:bg-primary/30" })] }), _jsx("figcaption", { className: "text-xs text-muted-foreground", children: t("documentDesigner.preview.pageOf", { page: pageNumber, pages: document.numPages }) })] }));
85
- }
86
- const pageCache = new WeakMap();
87
- function pageFor(document, pageNumber) {
88
- let pages = pageCache.get(document);
89
- if (pages === undefined) {
90
- pages = new Map();
91
- pageCache.set(document, pages);
92
- }
93
- let page = pages.get(pageNumber);
94
- if (page === undefined) {
95
- page = document.getPage(pageNumber);
96
- pages.set(pageNumber, page);
97
- }
98
- return page;
99
- }
100
- function Pages({ pdfBase64 }) {
101
- const t = useTranslation();
102
- const loaded = use(documentFor(pdfBase64));
103
- if (!loaded.ok) {
104
- return _jsx("p", { className: "text-sm text-status-bad", children: t("documentDesigner.preview.renderFailed") });
105
- }
106
- const pageNumbers = Array.from({ length: loaded.document.numPages }, (_, index) => index + 1);
107
- return (_jsx(_Fragment, { children: pageNumbers.map((pageNumber) => (_jsx(Suspense, { fallback: null, children: _jsx(PageView, { document: loaded.document, pageNumber: pageNumber }) }, pageNumber))) }));
108
- }
109
- export function PdfPreview({ pdfBase64 }) {
110
- const t = useTranslation();
111
- return (_jsx("div", { className: "flex flex-col items-center gap-6 overflow-auto bg-muted p-6", "data-testid": "document-designer-pdf-preview", children: _jsx(Suspense, { fallback: _jsx("p", { className: "text-sm text-muted-foreground", children: t("documentDesigner.preview.loading") }), children: _jsx(Pages, { pdfBase64: pdfBase64 }) }) }));
112
- }