@cosmicdrift/kumiko-document-designer 0.363.0 → 0.365.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.
Files changed (90) hide show
  1. package/dist/constants.d.ts +3 -0
  2. package/dist/constants.js +4 -0
  3. package/dist/i18n.js +208 -50
  4. package/dist/web/cells.js +4 -3
  5. package/dist/web/editor-header.d.ts +37 -0
  6. package/dist/web/editor-header.js +43 -0
  7. package/dist/web/editor-locale.d.ts +1 -0
  8. package/dist/web/editor-locale.js +15 -0
  9. package/dist/web/editor-shell.d.ts +40 -0
  10. package/dist/web/editor-shell.js +36 -0
  11. package/dist/web/i18n.js +35 -59
  12. package/dist/web/layout-editor-body.d.ts +2 -2
  13. package/dist/web/layout-editor-body.js +101 -52
  14. package/dist/web/layout-editor.js +14 -7
  15. package/dist/web/layout-html.d.ts +2 -0
  16. package/dist/web/layout-html.js +13 -1
  17. package/dist/web/layout-meta.d.ts +29 -0
  18. package/dist/web/layout-meta.js +32 -0
  19. package/dist/web/layout-rails.d.ts +15 -5
  20. package/dist/web/layout-rails.js +61 -68
  21. package/dist/web/layout-types.d.ts +4 -0
  22. package/dist/web/layout-users.d.ts +1 -0
  23. package/dist/web/layout-users.js +4 -1
  24. package/dist/web/mail-dialogs.d.ts +1 -6
  25. package/dist/web/mail-dialogs.js +2 -2
  26. package/dist/web/mail-draft.d.ts +8 -6
  27. package/dist/web/mail-draft.js +45 -11
  28. package/dist/web/mail-edit-center.d.ts +3 -1
  29. package/dist/web/mail-edit-center.js +5 -4
  30. package/dist/web/mail-editor-body.d.ts +0 -1
  31. package/dist/web/mail-editor-body.js +60 -45
  32. package/dist/web/mail-editor.js +9 -14
  33. package/dist/web/mail-footer.d.ts +3 -14
  34. package/dist/web/mail-footer.js +12 -13
  35. package/dist/web/mail-left-rail.d.ts +9 -4
  36. package/dist/web/mail-left-rail.js +18 -15
  37. package/dist/web/mail-missing-locale.d.ts +10 -0
  38. package/dist/web/mail-missing-locale.js +45 -0
  39. package/dist/web/mail-origin-bar.d.ts +3 -5
  40. package/dist/web/mail-origin-bar.js +26 -50
  41. package/dist/web/mail-preview-side.d.ts +12 -5
  42. package/dist/web/mail-preview-side.js +14 -5
  43. package/dist/web/mail-preview.d.ts +9 -5
  44. package/dist/web/mail-preview.js +24 -13
  45. package/dist/web/mail-right-rail.d.ts +8 -0
  46. package/dist/web/mail-right-rail.js +65 -14
  47. package/dist/web/mail-types.d.ts +10 -1
  48. package/dist/web/mail-upload-modal.js +12 -2
  49. package/dist/web/mail-versions.js +3 -3
  50. package/dist/web/origin-bar.d.ts +11 -2
  51. package/dist/web/origin-bar.js +46 -24
  52. package/dist/web/overrides-drawer.d.ts +3 -2
  53. package/dist/web/overrides-drawer.js +87 -11
  54. package/dist/web/overview-row.d.ts +2 -0
  55. package/dist/web/overview-row.js +2 -1
  56. package/dist/web/page-canvas.d.ts +2 -1
  57. package/dist/web/page-canvas.js +23 -13
  58. package/dist/web/pdf-footer.d.ts +13 -0
  59. package/dist/web/pdf-footer.js +34 -0
  60. package/dist/web/pdf-left-rail.d.ts +9 -0
  61. package/dist/web/pdf-left-rail.js +16 -0
  62. package/dist/web/pdf-meta.d.ts +3 -5
  63. package/dist/web/pdf-meta.js +10 -16
  64. package/dist/web/pdf-preview.js +21 -8
  65. package/dist/web/pdf-template-editor.js +91 -76
  66. package/dist/web/rail.d.ts +13 -1
  67. package/dist/web/rail.js +15 -7
  68. package/dist/web/relative-time.d.ts +1 -0
  69. package/dist/web/relative-time.js +18 -0
  70. package/dist/web/settings-panel.d.ts +11 -5
  71. package/dist/web/settings-panel.js +24 -13
  72. package/dist/web/status-label.d.ts +7 -0
  73. package/dist/web/status-label.js +8 -0
  74. package/dist/web/types.d.ts +23 -0
  75. package/dist/web/use-debounced-value.d.ts +1 -0
  76. package/dist/web/use-debounced-value.js +11 -0
  77. package/dist/web/use-insert-targets.d.ts +2 -2
  78. package/dist/web/use-insert-targets.js +4 -1
  79. package/dist/web/use-mail-persistence.d.ts +2 -2
  80. package/dist/web/use-mail-persistence.js +4 -4
  81. package/dist/web/use-own-address.d.ts +5 -0
  82. package/dist/web/use-own-address.js +14 -0
  83. package/dist/web/used-by-label.d.ts +1 -0
  84. package/dist/web/used-by-label.js +11 -0
  85. package/package.json +8 -8
  86. package/src/changes.json +48 -0
  87. package/dist/web/mail-header.d.ts +0 -23
  88. package/dist/web/mail-header.js +0 -16
  89. package/dist/web/placeholder-panel.d.ts +0 -21
  90. package/dist/web/placeholder-panel.js +0 -35
@@ -3,36 +3,48 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
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
5
  import { RendererPuppeteerClientQueries } from "@cosmicdrift/kumiko-renderer-puppeteer-client/qualified-names";
6
- import { ErrorState, LoadingState, ModeSwitch, StatusBadge, uploadFileRef, } from "@cosmicdrift/kumiko-renderer-web";
7
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
+ import { ErrorState, LoadingState, uploadFileRef } from "@cosmicdrift/kumiko-renderer-web";
7
+ import { useEffect, useMemo, useRef, useState } from "react";
8
+ import { BRANDING_QUERY_QN } from "../constants.js";
9
+ import { EditorHeader, EditorLocaleSwitch, EditorModeSwitch, originOf, } from "./editor-header.js";
10
+ import { EditorBody, EditorFooter, EditorFrame, EditorInfoBar } from "./editor-shell.js";
8
11
  import { describeError, isAlreadyActive } from "./errors.js";
12
+ import { fileUrl } from "./file-name.js";
13
+ import { systemLabelMap, variableLabelMap } from "./mail-draft.js";
14
+ import { PlaceholderSection, SystemPlaceholderSection } from "./mail-left-rail.js";
9
15
  import { OverridesDrawer } from "./overrides-drawer.js";
10
16
  import { PageCanvas } from "./page-canvas.js";
17
+ import { pdfFooterMessage } from "./pdf-footer.js";
18
+ import { PdfResourceSection, visibleSystemPlaceholders } from "./pdf-left-rail.js";
11
19
  import { EMPTY_PDF_META, parsePdfMeta, serializePdfMeta } from "./pdf-meta.js";
12
20
  import { PdfPreview } from "./pdf-preview.js";
13
- import { PlaceholderPanel, visibleSystemPlaceholders, } from "./placeholder-panel.js";
14
21
  import { SettingsPanel } from "./settings-panel.js";
22
+ import { draftBesideActiveSentence } from "./status-label.js";
15
23
  import { localesOf, parseJsonObject, pickRow, resolveLocale } from "./template-rows.js";
16
24
  import { DEFAULT_LAYOUT_SLUG, DOCUMENT_PDF_KIND, PDF_LAYOUT_KIND, } from "./types.js";
25
+ import { useInsertTargets } from "./use-insert-targets.js";
26
+ import { useOverviewRows } from "./use-overview-rows.js";
27
+ import { templateTitle } from "./used-by-label.js";
17
28
  const SETTLE_MS = 500;
18
29
  const NO_VARIABLES = {};
19
- const SYSTEM_LABEL_NAMES = ["tenantName", "imprint", "signature", "page", "pages"];
20
- // Page numbers share one row in the panel; "pages" is inserted with "page".
21
- const MERGED_SYSTEM_PLACEHOLDERS = new Set(["pages"]);
30
+ // Opening the preview prints the page without a click; a failed print is not retried for the same draft.
31
+ function RunOnMount({ run }) {
32
+ useEffect(() => {
33
+ run();
34
+ }, [run]);
35
+ return null;
36
+ }
22
37
  const draftFromDetail = (detail) => ({
23
38
  content: detail.content,
24
39
  meta: parsePdfMeta(detail.meta),
25
40
  });
26
41
  const snapshotOf = (draft) => JSON.stringify({ content: draft.content, meta: serializePdfMeta(draft.meta) });
27
- function formatSize(bytes) {
28
- return bytes < 1024 * 1024
29
- ? `${Math.max(1, Math.round(bytes / 1024))} KB`
30
- : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
31
- }
32
42
  export function PdfTemplateEditor({ slug, locale: requestedLocale, onLocaleChange, }) {
33
43
  const t = useTranslation();
34
44
  const dispatcher = useDispatcher();
35
45
  const { Button, Banner, Dialog } = usePrimitives();
46
+ const insert = useInsertTargets();
47
+ const overview = useOverviewRows(DOCUMENT_PDF_KIND);
36
48
  const roles = useUserRoles();
37
49
  const scope = roles?.includes("SystemAdmin") === true ? "system" : "tenant";
38
50
  const rowsQuery = useQuery(TemplateResolverQueries.list, {
@@ -61,11 +73,12 @@ export function PdfTemplateEditor({ slug, locale: requestedLocale, onLocaleChang
61
73
  clearTimeout(settleTimer.current);
62
74
  }, []);
63
75
  const [overridesOpen, setOverridesOpen] = useState(false);
64
- const editorHandle = useRef(null);
76
+ const [draftEpoch, setDraftEpoch] = useState(0);
65
77
  const [mode, setMode] = useState("edit");
66
78
  const [busy, setBusy] = useState(null);
67
79
  const [actionError, setActionError] = useState(null);
68
80
  const [generated, setGenerated] = useState(null);
81
+ const autoPrintedFor = useRef(null);
69
82
  const [pendingLocale, setPendingLocale] = useState(null);
70
83
  const variableSchema = useMemo(() => parseJsonObject(detail?.variableSchema ?? null), [detail?.variableSchema]);
71
84
  const settledMeta = current === null ? null : serializePdfMeta(current.settled.meta);
@@ -90,35 +103,25 @@ export function PdfTemplateEditor({ slug, locale: requestedLocale, onLocaleChang
90
103
  variableSchema,
91
104
  }, { enabled: detail !== null });
92
105
  const layout = previewQuery.error === null ? (previewQuery.data?.layout ?? null) : null;
93
- const layoutsQuery = useQuery(TemplateResolverQueries.list, {
94
- kind: PDF_LAYOUT_KIND,
95
- locale,
96
- });
97
- const layoutSlugs = [
98
- ...new Set((layoutsQuery.data ?? []).map((layoutRow) => layoutRow.slug)),
99
- ].sort();
106
+ const layouts = useOverviewRows(PDF_LAYOUT_KIND);
107
+ // The built-in "logo" resource of a layout is the tenant's branding logo.
108
+ const branding = useQuery(BRANDING_QUERY_QN, {});
109
+ const brandingLogoFile = branding.data?.logoFile ?? "";
100
110
  const statusQuery = useQuery(RendererPuppeteerClientQueries.status, {});
101
111
  const pdfAvailable = statusQuery.error === null && statusQuery.data?.available === true;
102
112
  const unavailableMessage = statusQuery.data?.available === false ? (statusQuery.data.message ?? "") : "";
103
113
  const showOrigin = scope === "system";
104
114
  const overridesQuery = useQuery(TemplateResolverQueries.tenantOverrides, { slug, kind: DOCUMENT_PDF_KIND, locale }, { enabled: showOrigin && detail !== null });
105
115
  const variables = validation?.variables ?? NO_VARIABLES;
106
- const systemLabel = useCallback((name) => {
107
- const label = t(`documentDesigner.system.${name}`);
108
- return label === `documentDesigner.system.${name}` ? name : label;
109
- }, [t]);
110
- const variableLabels = useMemo(() => Object.fromEntries(Object.entries(variables).flatMap(([name, info]) => info.label === undefined ? [] : [[name, info.label]])), [variables]);
111
- const systemLabels = useMemo(() => Object.fromEntries(SYSTEM_LABEL_NAMES.map((name) => [name, systemLabel(name)])), [systemLabel]);
112
116
  const systemVariables = useMemo(() => visibleSystemPlaceholders(validation?.systemPlaceholders ?? []), [validation?.systemPlaceholders]);
117
+ const systemLabels = useMemo(() => systemLabelMap(t, systemVariables), [t, systemVariables]);
118
+ const variableLabels = useMemo(() => ({ ...systemLabels, ...variableLabelMap(variables) }), [systemLabels, variables]);
113
119
  const variableNames = useMemo(() => Object.keys(variables), [variables]);
114
120
  const linkedResources = detail?.linkedResources ?? null;
115
121
  // The editor shows resource images from the stored copies; no extra request is made here.
116
122
  const resourcePreviews = useMemo(() => Object.fromEntries(Object.entries(parseJsonObject(linkedResources)).flatMap(([key, fileRefId]) => typeof fileRefId === "string"
117
123
  ? [[key, { src: `/api/files/${encodeURIComponent(fileRefId)}`, caption: key }]]
118
124
  : [])), [linkedResources]);
119
- const onEditorReady = useCallback((handle) => {
120
- editorHandle.current = handle;
121
- }, []);
122
125
  if (rowsQuery.loading && rowsQuery.data === null)
123
126
  return _jsx(LoadingState, {});
124
127
  if (rowsQuery.error !== null)
@@ -143,6 +146,7 @@ export function PdfTemplateEditor({ slug, locale: requestedLocale, onLocaleChang
143
146
  if (settleTimer.current !== null)
144
147
  clearTimeout(settleTimer.current);
145
148
  setState({ key: source.id, draft, settled: draft, snapshot: snapshotOf(draft) });
149
+ setDraftEpoch((epoch) => epoch + 1);
146
150
  setActionError(null);
147
151
  }
148
152
  async function reload() {
@@ -220,10 +224,11 @@ export function PdfTemplateEditor({ slug, locale: requestedLocale, onLocaleChang
220
224
  setActionError(describeError(t, result.error));
221
225
  setBusy(null);
222
226
  }
223
- function switchMode(next) {
224
- setMode(next);
225
- if (next === "preview" && pdfAvailable && !generatedFresh && busy === null)
226
- void generatePdf();
227
+ function printOnce() {
228
+ if (current === null || autoPrintedFor.current === snapshotOf(current.draft))
229
+ return;
230
+ autoPrintedFor.current = snapshotOf(current.draft);
231
+ void generatePdf();
227
232
  }
228
233
  async function uploadResource(file) {
229
234
  const sourceFileRefId = await uploadFileRef(file);
@@ -243,53 +248,63 @@ export function PdfTemplateEditor({ slug, locale: requestedLocale, onLocaleChang
243
248
  else
244
249
  onLocaleChange?.(next);
245
250
  }
246
- const used = validation?.used ?? [];
247
- const missingRequired = validation?.missingRequired ?? [];
248
- const layoutName = layout?.slug ?? DEFAULT_LAYOUT_SLUG;
249
- const systemNames = visibleSystemPlaceholders(validation?.systemPlaceholders ?? []).filter((name) => !MERGED_SYSTEM_PLACEHOLDERS.has(name));
250
- const systemRows = systemNames.map((name) => ({
251
- name,
252
- label: systemLabel(name),
253
- status: used.includes(name)
254
- ? "used"
255
- : layout !== null &&
256
- (layout.html.includes(`data-variable="${name}"`) || layout.html.includes(`{{${name}}}`))
257
- ? "layout"
258
- : "unused",
259
- }));
260
- const overrideCount = (overridesQuery.data?.activeOverrides ?? 0) + (overridesQuery.data?.draftOverrides ?? 0);
261
251
  const canPublish = validation?.canPublish === true;
262
- const issueTexts = [...(validation?.issues ?? []), ...(validation?.publishIssues ?? [])].map((issue) => t(issue.i18nKey, issue.params));
252
+ const layoutSlugShown = layout?.slug ?? DEFAULT_LAYOUT_SLUG;
253
+ const layoutName = layouts.rows.find((row) => row.slug === layoutSlugShown)?.title ?? layoutSlugShown;
254
+ const layoutResourceSrc = (key) => key === "logo" && brandingLogoFile !== "" ? fileUrl(brandingLogoFile) : undefined;
255
+ const placeholderSource = validation === undefined || validation === null
256
+ ? null
257
+ : {
258
+ ...validation,
259
+ systemPlaceholders: visibleSystemPlaceholders(validation.systemPlaceholders),
260
+ };
261
+ const layoutFills = (name) => layout !== null &&
262
+ (layout.html.includes(`data-variable="${name}"`) || layout.html.includes(`{{${name}}}`));
263
+ const overrideCount = (overridesQuery.data?.activeOverrides ?? 0) + (overridesQuery.data?.draftOverrides ?? 0);
263
264
  const resourceKeys = Object.keys(parseJsonObject(detail.linkedResources));
264
- const actions = (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", disabled: !dirty || busy !== null, onClick: () => resetDraft(detail), children: t("documentDesigner.actions.discard") }), _jsx(Button, { variant: "secondary", disabled: busy !== null || validation?.canSave === false, loading: busy === "save", onClick: () => void save(), children: t("documentDesigner.actions.save") }), _jsx(Button, { variant: "primary", disabled: busy !== null || !canPublish, loading: busy === "publish", onClick: () => void publish(), children: t("documentDesigner.actions.publish") })] }));
265
- return (_jsxs("div", { className: "flex flex-col gap-3", "data-testid": "pdf-template-editor", children: [_jsxs("header", { className: "flex flex-wrap items-center gap-3", children: [_jsxs("nav", { className: "text-sm text-muted-foreground", children: [scope === "system" ? `${t("documentDesigner.breadcrumb.platform")} › ` : "", t("documentDesigner.breadcrumb.templates"), " \u203A", " ", _jsx("span", { className: "text-foreground", children: slug })] }), _jsx(StatusBadge, { tone: detail.status === "active" ? "ok" : "warn", children: t(detail.status === "active"
266
- ? "documentDesigner.status.activeVersion"
267
- : "documentDesigner.status.draftVersion", { version: detail.version }) }), _jsx(StatusBadge, { tone: "muted", children: t(detail.scope === "system"
268
- ? "documentDesigner.origin.system"
269
- : "documentDesigner.origin.tenant") }), _jsx(ModeSwitch, { ariaLabel: t("documentDesigner.locale"), value: locale, onChange: requestLocale, options: (locales.length > 0 ? locales : [locale]).map((value) => ({
270
- value,
271
- label: value.toUpperCase(),
272
- })) }), _jsx(ModeSwitch, { ariaLabel: t("documentDesigner.mode.label"), variant: "pill", value: mode, onChange: switchMode, options: [
273
- { value: "edit", label: t("documentDesigner.mode.edit") },
274
- { value: "preview", label: t("documentDesigner.mode.preview") },
275
- ] }), _jsxs("div", { className: "ml-auto flex items-center gap-2", children: [_jsx(Button, { variant: "secondary", disabled: !pdfAvailable || busy !== null, loading: busy === "pdf", onClick: () => void generatePdf(), children: t("documentDesigner.actions.generatePdf") }), actions] })] }), showOrigin && detail.scope === "system" && (_jsx(Banner, { variant: "info", actions: _jsxs("div", { className: "flex flex-wrap items-center gap-4", 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: overridesQuery.data?.totalTenants == null
276
- ? t("documentDesigner.originBar.noTotal", { count: overrideCount })
277
- : t("documentDesigner.originBar.withTotal", {
278
- count: overrideCount,
279
- total: overridesQuery.data.totalTenants,
280
- }) })), _jsx(OverridesDrawer, { open: overridesOpen, onOpenChange: setOverridesOpen, slug: slug, overrides: overridesQuery.data }), !pdfAvailable && statusQuery.loading === false && (_jsx(Banner, { variant: "warning", children: t("documentDesigner.pdfUnavailable", { message: unavailableMessage }) })), actionError !== null && _jsx(Banner, { variant: "error", children: actionError }), _jsxs("div", { className: "grid grid-cols-[260px_minmax(0,1fr)_300px] items-start gap-4", children: [_jsx(PlaceholderPanel, { variables: variables, used: used, missingRequired: missingRequired, systemRows: systemRows, resourceKeys: resourceKeys, canUpload: detail.scope === scope, layoutName: layoutName, onInsert: (name) => editorHandle.current?.insertPlaceholder(name), onInsertResource: (key) => editorHandle.current?.insertResource(key), onUpload: uploadResource }), mode === "edit" ? (_jsx(PageCanvas, { meta: current.draft.meta, content: current.draft.content, onContentChange: (content) => updateDraft({ ...current.draft, content }), regions: layout?.regions ?? null, layoutName: layoutName, variableLabels: variableLabels, systemVariables: systemVariables, systemLabels: systemLabels, variableNames: variableNames, resourcePreviews: resourcePreviews, onEditorReady: onEditorReady })) : generated !== null && generatedFresh ? (_jsx(PdfPreview, { pdfBase64: generated.result.pdfBase64 })) : (_jsxs("div", { className: "flex flex-col items-start gap-3 bg-muted p-6", "data-testid": "document-designer-preview-stale", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: busy === "pdf"
265
+ const overviewRow = overview.rows.find((candidate) => candidate.slug === slug);
266
+ const title = overviewRow === undefined ? slug : templateTitle(t, overviewRow);
267
+ const systemVersion = overviewRow?.systemVersion ?? null;
268
+ const vocabulary = {
269
+ names: variableNames,
270
+ labels: variableLabels,
271
+ system: systemVariables,
272
+ };
273
+ const footer = validation === undefined || validation === null
274
+ ? null
275
+ : pdfFooterMessage(t, validation, generated === null
276
+ ? null
277
+ : {
278
+ pages: generated.result.pageCount,
279
+ sizeBytes: generated.result.sizeBytes,
280
+ durationMs: generated.result.durationMs,
281
+ });
282
+ const showOverrideLinks = showOrigin && detail.scope === "system";
283
+ const originText = showOverrideLinks
284
+ ? overridesQuery.data?.totalTenants == null
285
+ ? t("documentDesigner.originBar.noTotal", { count: overrideCount })
286
+ : t("documentDesigner.originBar.withTotal", {
287
+ count: overrideCount,
288
+ total: overridesQuery.data.totalTenants,
289
+ })
290
+ : detail.scope === "system"
291
+ ? t("documentDesigner.mail.origin.system", { version: detail.version })
292
+ : systemVersion === null
293
+ ? t("documentDesigner.mail.origin.tenantOnly")
294
+ : t("documentDesigner.mail.origin.overrides", { version: systemVersion });
295
+ const besideActiveSentence = draftBesideActiveSentence(t, detail);
296
+ const pdfUnavailableNote = !pdfAvailable && statusQuery.loading === false;
297
+ return (_jsxs(EditorFrame, { testId: "pdf-template-editor", children: [_jsx(EditorHeader, { title: title, stored: { status: detail.status, version: detail.version }, actions: {
298
+ busy: busy === "save",
299
+ canSave: busy === null && validation?.canSave !== false,
300
+ canPublish: busy === null && canPublish,
301
+ onSave: () => void save(),
302
+ onPublish: () => void publish(),
303
+ }, 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
+ ? 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"
281
306
  ? t("documentDesigner.preview.loading")
282
- : t("documentDesigner.preview.stale") }), _jsx(Button, { variant: "secondary", disabled: !pdfAvailable || busy !== null, onClick: () => void generatePdf(), children: t("documentDesigner.actions.generatePdf") })] })), _jsx(SettingsPanel, { meta: current.draft.meta, onMetaChange: (meta) => updateDraft({ ...current.draft, meta }), layoutSlugs: layoutSlugs, locale: locale, version: {
283
- version: detail.version,
284
- status: detail.status,
285
- modifiedAt: detail.modifiedAt,
286
- } })] }), _jsxs("footer", { className: "flex flex-wrap items-start justify-between gap-3 border-t border-border pt-3", children: [_jsxs("ul", { className: "m-0 flex list-none flex-col gap-1 p-0 text-sm", "data-testid": "document-designer-validation", children: [missingRequired.length === 0 && validation !== undefined && validation !== null && (_jsx("li", { className: "text-status-ok", children: t("documentDesigner.validation.ok") })), missingRequired.map((name) => (_jsx("li", { className: "text-status-bad", children: t("documentDesigner.validation.missingRequired", {
287
- name: variables[name]?.label ?? name,
288
- }) }, name))), issueTexts.map((text) => (_jsx("li", { className: "text-status-bad", children: text }, text))), (validation?.warnings ?? []).map((warning) => (_jsx("li", { className: "text-status-warn", children: t(warning.i18nKey, { detail: warning.detail }) }, `${warning.code}:${warning.detail}`))), generated !== null && (_jsx("li", { className: "text-muted-foreground", children: t("documentDesigner.validation.lastProbe", {
289
- pages: generated.result.pageCount,
290
- size: formatSize(generated.result.sizeBytes),
291
- seconds: (generated.result.durationMs / 1000).toFixed(1),
292
- }) }))] }), _jsx("div", { className: "flex items-center gap-2", children: actions })] }), _jsx(Dialog, { open: pendingLocale !== null, onOpenChange: (open) => {
307
+ : 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) => {
293
308
  if (!open)
294
309
  setPendingLocale(null);
295
310
  }, title: t("documentDesigner.localeDialog.title"), description: t("documentDesigner.localeDialog.description"), confirmLabel: t("documentDesigner.localeDialog.save"), cancelLabel: t("documentDesigner.localeDialog.cancel"), onConfirm: async () => {
@@ -5,10 +5,21 @@ export declare function RailSection({ title, action, testId, children, }: {
5
5
  readonly testId?: string;
6
6
  readonly children: ReactNode;
7
7
  }): ReactNode;
8
- export declare function RailRow({ symbol, label, trailing, bad, testId, onPress, }: {
8
+ export declare function RailField({ children }: {
9
+ readonly children: ReactNode;
10
+ }): ReactNode;
11
+ declare const TRAILING_TONE: {
12
+ readonly ok: "text-status-ok";
13
+ readonly bad: "text-status-bad";
14
+ readonly muted: "text-muted-foreground";
15
+ };
16
+ export declare function RailRow({ symbol, label, required, trailing, trailingTone, action, bad, testId, onPress, }: {
9
17
  readonly symbol?: string;
10
18
  readonly label: string;
19
+ readonly required?: boolean;
11
20
  readonly trailing?: ReactNode;
21
+ readonly trailingTone?: keyof typeof TRAILING_TONE;
22
+ readonly action?: ReactNode;
12
23
  readonly bad?: boolean;
13
24
  readonly testId?: string;
14
25
  readonly onPress?: () => void;
@@ -16,3 +27,4 @@ export declare function RailRow({ symbol, label, trailing, bad, testId, onPress,
16
27
  export declare function RailHint({ children }: {
17
28
  readonly children: ReactNode;
18
29
  }): ReactNode;
30
+ export {};
package/dist/web/rail.js CHANGED
@@ -1,14 +1,22 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // @runtime client
3
- import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
4
3
  import { cn } from "@cosmicdrift/kumiko-renderer-web";
5
4
  export function RailSection({ title, action, testId, children, }) {
6
- return (_jsxs("section", { className: "flex flex-col gap-1", "data-testid": testId, children: [_jsxs("div", { className: "flex items-center gap-2 pt-3 pb-1", children: [_jsx("span", { className: "text-xs font-semibold uppercase tracking-wide text-muted-foreground", children: title }), _jsx("div", { className: "grow" }), action] }), children] }));
5
+ return (_jsxs("section", { className: "flex flex-col border-b pb-1", "data-testid": testId, children: [_jsxs("div", { className: "flex items-center gap-2 px-3.5 pt-2.5 pb-1", children: [_jsx("span", { className: "text-xs font-medium uppercase tracking-wider text-muted-foreground", children: title }), _jsx("div", { className: "grow" }), action] }), children] }));
7
6
  }
8
- export function RailRow({ symbol, label, trailing, bad, testId, onPress, }) {
9
- const { Button } = usePrimitives();
10
- return (_jsxs("div", { className: cn("flex items-center gap-2 rounded px-2 py-1", bad === true && "bg-status-bad-surface text-status-bad"), "data-testid": testId, "data-bad": bad === true ? "true" : undefined, children: [symbol !== undefined && (_jsx("span", { className: "w-4 shrink-0 text-center text-xs text-muted-foreground", children: symbol })), _jsx("span", { className: "min-w-0 grow truncate text-sm", children: onPress === undefined ? (label) : (_jsx(Button, { variant: "link", onClick: onPress, ...(testId !== undefined && { testId: `${testId}-insert` }), children: label })) }), trailing !== undefined && (_jsx("span", { className: "min-w-0 max-w-[60%] truncate text-right text-xs text-muted-foreground", title: typeof trailing === "string" ? trailing : undefined, children: trailing }))] }));
7
+ export function RailField({ children }) {
8
+ return _jsx("div", { className: "flex flex-col gap-3 px-3.5 py-2", children: children });
9
+ }
10
+ const TRAILING_TONE = {
11
+ ok: "text-status-ok",
12
+ bad: "text-status-bad",
13
+ muted: "text-muted-foreground",
14
+ };
15
+ export function RailRow({ symbol, label, required, trailing, trailingTone = "muted", action, bad, testId, onPress, }) {
16
+ const content = (_jsxs(_Fragment, { children: [symbol !== undefined && (_jsx("span", { className: "inline-flex h-[18px] w-[22px] shrink-0 items-center justify-center rounded-sm bg-muted font-mono text-xs text-muted-foreground", children: symbol })), _jsxs("span", { className: cn("min-w-0 grow break-words text-sm", trailing !== undefined && "max-w-[65%] shrink-0", bad === true && "text-status-bad"), children: [label, required === true && _jsx("span", { className: "font-semibold text-status-bad", children: " *" })] }), trailing !== undefined && (_jsx("span", { className: cn("min-w-0 truncate text-right text-xs", bad === true ? "text-status-bad" : TRAILING_TONE[trailingTone]), title: typeof trailing === "string" ? trailing : undefined, children: trailing }))] }));
17
+ const rowClass = "flex min-h-10 min-w-0 grow items-center gap-2 px-3.5 text-left";
18
+ return (_jsxs("div", { className: cn("flex items-center border-b", bad === true && "bg-status-bad-surface"), "data-testid": testId, "data-bad": bad === true ? "true" : undefined, children: [onPress === undefined ? (_jsx("div", { className: rowClass, children: content })) : (_jsx("button", { type: "button", onClick: onPress, className: cn(rowClass, "hover:bg-muted"), ...(testId !== undefined && { "data-testid": `${testId}-insert` }), children: content })), action !== undefined && _jsx("div", { className: "shrink-0 pr-2", children: action })] }));
11
19
  }
12
20
  export function RailHint({ children }) {
13
- return _jsx("p", { className: "px-2 pt-1 text-xs text-muted-foreground", children: children });
21
+ return _jsx("p", { className: "px-3.5 pt-1 pb-2 text-xs text-muted-foreground", children: children });
14
22
  }
@@ -0,0 +1 @@
1
+ export declare function formatRelativeWhen(value: string, now?: number): string;
@@ -0,0 +1,18 @@
1
+ const UNITS = [
2
+ ["day", 86_400_000],
3
+ ["hour", 3_600_000],
4
+ ["minute", 60_000],
5
+ ];
6
+ // Falls back to the raw value for an unparseable timestamp, like formatWhen.
7
+ export function formatRelativeWhen(value, now = Date.now()) {
8
+ const then = Date.parse(value);
9
+ if (Number.isNaN(then))
10
+ return value;
11
+ const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
12
+ const elapsed = then - now;
13
+ for (const [unit, size] of UNITS) {
14
+ if (Math.abs(elapsed) >= size)
15
+ return formatter.format(Math.round(elapsed / size), unit);
16
+ }
17
+ return formatter.format(0, "minute");
18
+ }
@@ -1,17 +1,23 @@
1
1
  import type { ReactNode } from "react";
2
+ import type { EditorVocabulary } from "./mail-edit-center.js";
3
+ import type { OverviewRowView } from "./overview-row.js";
2
4
  import { type PdfMetaDraft } from "./pdf-meta.js";
3
- import type { TemplateStatus } from "./types.js";
5
+ import { type TemplateStatus } from "./types.js";
6
+ import type { InsertTargets } from "./use-insert-targets.js";
4
7
  export type VersionInfo = {
8
+ readonly id: string;
5
9
  readonly version: number;
6
10
  readonly status: TemplateStatus;
7
- readonly modifiedAt: string | null;
8
11
  };
9
12
  export type SettingsPanelProps = {
10
13
  readonly meta: PdfMetaDraft;
11
14
  readonly onMetaChange: (next: PdfMetaDraft) => void;
12
- /** Layout slugs available for this locale; the default layout is always offered. */
13
- readonly layoutSlugs: readonly string[];
15
+ readonly layouts: readonly OverviewRowView[];
14
16
  readonly locale: string;
15
17
  readonly version: VersionInfo;
18
+ readonly vocabulary: EditorVocabulary;
19
+ readonly insert: InsertTargets;
20
+ /** Changes when the draft is replaced from outside, so the single-line editors re-read it. */
21
+ readonly draftKey: string;
16
22
  };
17
- export declare function SettingsPanel({ meta, onMetaChange, layoutSlugs, locale, version, }: SettingsPanelProps): ReactNode;
23
+ export declare function SettingsPanel({ meta, onMetaChange, layouts, locale, version, vocabulary, insert, draftKey, }: SettingsPanelProps): ReactNode;
@@ -1,25 +1,36 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // @runtime client
3
+ import { PDF_ORIENTATIONS, PDF_PAGE_FORMATS, PlaceholderLineEditor, } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
3
4
  import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
4
- import { ModeSwitch, SectionCard, StatusBadge } from "@cosmicdrift/kumiko-renderer-web";
5
- import { MARGIN_SIDES, ORIENTATIONS, PAGE_FORMATS, } from "./pdf-meta.js";
5
+ import { languageName } from "./mail-origin-bar.js";
6
+ import { layoutOptionLabel } from "./mail-right-rail.js";
7
+ import { VersionsSection } from "./mail-versions.js";
8
+ import { MARGIN_SIDES } from "./pdf-meta.js";
9
+ import { RailField, RailHint, RailSection } from "./rail.js";
10
+ import { DEFAULT_LAYOUT_SLUG } from "./types.js";
6
11
  const SIDE_LABEL_KEYS = {
7
12
  top: "documentDesigner.settings.marginTop",
8
13
  right: "documentDesigner.settings.marginRight",
9
14
  bottom: "documentDesigner.settings.marginBottom",
10
15
  left: "documentDesigner.settings.marginLeft",
11
16
  };
12
- export function SettingsPanel({ meta, onMetaChange, layoutSlugs, locale, version, }) {
17
+ export function SettingsPanel({ meta, onMetaChange, layouts, locale, version, vocabulary, insert, draftKey, }) {
13
18
  const t = useTranslation();
14
- const { Field, Input } = usePrimitives();
19
+ const { Field, Input, Text } = usePrimitives();
15
20
  const patch = (changes) => onMetaChange({ ...meta, ...changes });
16
- return (_jsxs("div", { className: "flex flex-col gap-4", "data-testid": "document-designer-settings", children: [_jsxs(SectionCard, { title: t("documentDesigner.settings.page"), children: [_jsx(Field, { id: "dd-layout", label: t("documentDesigner.settings.layout"), children: _jsx(Input, { kind: "select", id: "dd-layout", name: "layoutSlug", value: meta.layoutSlug, onChange: (layoutSlug) => patch({ layoutSlug }), display: "dropdown", options: [
17
- { value: "", label: t("documentDesigner.settings.layoutDefault") },
18
- ...layoutSlugs.map((slug) => ({ value: slug, label: slug })),
19
- ] }) }), _jsx(Field, { id: "dd-file-name", label: t("documentDesigner.settings.fileName"), children: _jsx(Input, { kind: "text", id: "dd-file-name", name: "fileName", value: meta.fileName, onChange: (fileName) => patch({ fileName }), placeholder: "rechnung-{{number}}.pdf" }) }), _jsx(ModeSwitch, { ariaLabel: t("documentDesigner.settings.pageFormat"), value: meta.pageFormat, onChange: (pageFormat) => patch({ pageFormat }), options: PAGE_FORMATS.map((value) => ({ value, label: value })) }), _jsx(ModeSwitch, { ariaLabel: t("documentDesigner.settings.orientation"), value: meta.orientation, onChange: (orientation) => patch({ orientation }), options: ORIENTATIONS.map((value) => ({
20
- value,
21
- label: t(`documentDesigner.settings.${value}`),
22
- })) }), _jsx("div", { className: "grid grid-cols-2 gap-2", children: MARGIN_SIDES.map((side) => (_jsx(Field, { id: `dd-margin-${side}`, label: t(SIDE_LABEL_KEYS[side]), children: _jsx(Input, { kind: "number", id: `dd-margin-${side}`, name: `margin-${side}`, value: meta.margins[side], onChange: (value) => patch({ margins: { ...meta.margins, [side]: value ?? "" } }), placeholder: "15" }) }, side))) }), _jsx(Field, { id: "dd-print-background", label: t("documentDesigner.settings.printBackground"), layout: "inline", children: _jsx(Input, { kind: "boolean", id: "dd-print-background", name: "printBackground", value: meta.printBackground, onChange: (printBackground) => patch({ printBackground }) }) }), _jsx(Field, { id: "dd-layout-header-footer", label: t("documentDesigner.settings.layoutHeaderFooter"), layout: "inline", children: _jsx(Input, { kind: "boolean", id: "dd-layout-header-footer", name: "layoutHeaderFooter", value: meta.layoutHeaderFooter, onChange: (layoutHeaderFooter) => patch({ layoutHeaderFooter }) }) })] }), _jsxs(SectionCard, { title: t("documentDesigner.settings.metadata"), children: [_jsx(Field, { id: "dd-meta-title", label: t("documentDesigner.settings.metaTitle"), children: _jsx(Input, { kind: "text", id: "dd-meta-title", name: "metaTitle", value: meta.title, onChange: (title) => patch({ title }) }) }), _jsx(Field, { id: "dd-meta-author", label: t("documentDesigner.settings.metaAuthor"), children: _jsx(Input, { kind: "text", id: "dd-meta-author", name: "metaAuthor", value: meta.author, onChange: (author) => patch({ author }) }) }), _jsx(Field, { id: "dd-meta-language", label: t("documentDesigner.settings.metaLanguage"), children: _jsx(Input, { kind: "text", id: "dd-meta-language", name: "metaLanguage", value: locale, onChange: () => undefined, disabled: true }) })] }), _jsx(SectionCard, { title: t("documentDesigner.settings.storage"), children: _jsx("p", { className: "text-xs text-muted-foreground", children: t("documentDesigner.settings.storageHint") }) }), _jsx(SectionCard, { title: t("documentDesigner.settings.versions"), children: _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { children: `v${version.version}` }), _jsx("span", { className: "text-xs text-muted-foreground", children: version.modifiedAt?.slice(0, 10) ?? "" }), _jsx(StatusBadge, { tone: version.status === "active" ? "ok" : "warn", children: t(version.status === "active"
23
- ? "documentDesigner.status.active"
24
- : "documentDesigner.status.draft") })] }) })] }));
21
+ return (_jsxs("div", { "data-testid": "document-designer-settings", children: [_jsx(RailSection, { title: t("documentDesigner.settings.page"), testId: "pdf-editor-document", children: _jsxs(RailField, { children: [_jsx(Field, { id: "dd-layout", label: t("documentDesigner.settings.layout"), children: _jsx(Input, { kind: "select", id: "dd-layout", name: "layoutSlug", value: meta.layoutSlug === "" ? DEFAULT_LAYOUT_SLUG : meta.layoutSlug, onChange: (layoutSlug) => patch({ layoutSlug }), display: "dropdown", options: layouts.map((layout) => ({
22
+ value: layout.slug,
23
+ label: layoutOptionLabel(t, layout),
24
+ })) }) }), _jsx(Field, { id: "dd-file-name", label: t("documentDesigner.settings.fileName"), children: _jsx(Input, { kind: "text", id: "dd-file-name", name: "fileName", value: meta.fileName, onChange: (fileName) => patch({ fileName }), placeholder: "rechnung-{{number}}.pdf" }) }), _jsxs("div", { className: "grid grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)] gap-2", children: [_jsx(Field, { id: "dd-page-format", label: t("documentDesigner.settings.pageFormat"), children: _jsx(Input, { kind: "select", id: "dd-page-format", name: "pageFormat", value: meta.pageFormat, onChange: (pageFormat) => {
25
+ const next = PDF_PAGE_FORMATS.find((format) => format === pageFormat);
26
+ if (next !== undefined)
27
+ patch({ pageFormat: next });
28
+ }, display: "dropdown", options: PDF_PAGE_FORMATS.map((value) => ({ value, label: value })) }) }), _jsx(Field, { id: "dd-orientation", label: t("documentDesigner.settings.orientation"), children: _jsx(Input, { kind: "select", id: "dd-orientation", name: "orientation", value: meta.orientation, onChange: (orientation) => {
29
+ const next = PDF_ORIENTATIONS.find((candidate) => candidate === orientation);
30
+ if (next !== undefined)
31
+ patch({ orientation: next });
32
+ }, display: "dropdown", options: PDF_ORIENTATIONS.map((value) => ({
33
+ value,
34
+ label: t(`documentDesigner.settings.${value}`),
35
+ })) }) })] }), _jsxs("fieldset", { className: "m-0 flex min-w-0 flex-col gap-1 border-0 p-0", children: [_jsx("legend", { className: "p-0 text-sm font-medium", children: t("documentDesigner.settings.margins") }), _jsx("div", { className: "grid grid-cols-4 gap-1", children: MARGIN_SIDES.map((side) => (_jsx(Field, { id: `dd-margin-${side}`, label: t(SIDE_LABEL_KEYS[side]), hideLabel: true, children: _jsx(Input, { kind: "number", id: `dd-margin-${side}`, name: `margin-${side}`, value: meta.margins[side], onChange: (value) => patch({ margins: { ...meta.margins, [side]: value ?? "" } }), placeholder: "15" }) }, side))) })] }), _jsx(Field, { id: "dd-print-background", label: t("documentDesigner.settings.printBackground"), layout: "inline", children: _jsx(Input, { kind: "boolean", id: "dd-print-background", name: "printBackground", value: meta.printBackground, onChange: (printBackground) => patch({ printBackground }) }) }), _jsx(Field, { id: "dd-layout-header-footer", label: t("documentDesigner.settings.layoutHeaderFooter"), layout: "inline", children: _jsx(Input, { kind: "boolean", id: "dd-layout-header-footer", name: "layoutHeaderFooter", value: meta.layoutHeaderFooter, onChange: (layoutHeaderFooter) => patch({ layoutHeaderFooter }) }) })] }) }), _jsxs(RailSection, { title: t("documentDesigner.settings.metadata"), testId: "pdf-editor-metadata", children: [_jsxs(RailField, { children: [_jsx("div", { onFocusCapture: insert.markFocused("title"), "data-testid": "pdf-meta-title", children: _jsx(Field, { id: "dd-meta-title", label: t("documentDesigner.settings.metaTitle"), children: _jsx(PlaceholderLineEditor, { id: "dd-meta-title", value: meta.title, onChange: (title) => patch({ title }), variableLabels: vocabulary.labels, systemVariables: vocabulary.system, onEditorReady: insert.register("title"), "aria-label": t("documentDesigner.settings.metaTitle") }, `title:${draftKey}`) }) }), _jsx("div", { onFocusCapture: insert.markFocused("author"), "data-testid": "pdf-meta-author", children: _jsx(Field, { id: "dd-meta-author", label: t("documentDesigner.settings.metaAuthor"), children: _jsx(PlaceholderLineEditor, { id: "dd-meta-author", value: meta.author, onChange: (author) => patch({ author }), variableLabels: vocabulary.labels, systemVariables: vocabulary.system, onEditorReady: insert.register("author"), "aria-label": t("documentDesigner.settings.metaAuthor") }, `author:${draftKey}`) }) }), _jsxs("div", { className: "flex flex-col gap-1", "data-testid": "pdf-meta-language", children: [_jsx("span", { className: "text-sm font-medium", children: t("documentDesigner.settings.metaLanguage") }), _jsx(Text, { children: `${languageName(t, locale)} (${locale})` })] })] }), _jsx(RailHint, { children: t("documentDesigner.settings.metaLanguageHint") })] }), _jsx(RailSection, { title: t("documentDesigner.settings.storage"), testId: "pdf-editor-storage", children: _jsx(RailHint, { children: t("documentDesigner.settings.storageHint") }) }), _jsx(VersionsSection, { rowId: version.id, currentVersion: version.version, currentStatus: version.status })] }));
25
36
  }
@@ -3,5 +3,12 @@ import type { OverviewRowView } from "./overview-row.js";
3
3
  type Translate = (key: string, params?: Readonly<Record<string, unknown>>) => string;
4
4
  export declare function statusTone(status: OverviewRowView["status"]): StatusBadgeTone;
5
5
  export declare function statusLabel(t: Translate, status: OverviewRowView["status"], version: number): string;
6
+ export declare function draftBesideActiveSentence(t: Translate, row: {
7
+ readonly status: string;
8
+ readonly version: number;
9
+ readonly active?: {
10
+ readonly version: number;
11
+ } | null;
12
+ }): string | null;
6
13
  export declare function asRowStatus(status: string): OverviewRowView["status"];
7
14
  export {};
@@ -13,6 +13,14 @@ export function statusLabel(t, status, version) {
13
13
  ? "documentDesigner.status.activeVersion"
14
14
  : "documentDesigner.status.draftVersion", { version });
15
15
  }
16
+ export function draftBesideActiveSentence(t, row) {
17
+ if (asRowStatus(row.status) !== "draft" || row.active == null)
18
+ return null;
19
+ return t("documentDesigner.origin.draftBesideActive", {
20
+ active: row.active.version,
21
+ draft: row.version,
22
+ });
23
+ }
16
24
  export function asRowStatus(status) {
17
25
  return status === "active" || status === "archived" ? status : "draft";
18
26
  }
@@ -1,4 +1,5 @@
1
1
  import type { FieldIssue } from "@cosmicdrift/kumiko-headless";
2
+ import type { TemplateDiff } from "./layout-types.js";
2
3
  export type TemplateScope = "system" | "tenant";
3
4
  export type TemplateStatus = "draft" | "active" | "archived";
4
5
  export declare const DOCUMENT_PDF_KIND: "document-pdf";
@@ -31,12 +32,22 @@ export type TemplateDetail = {
31
32
  readonly parentTemplateId: string | null;
32
33
  readonly status: TemplateStatus;
33
34
  readonly modifiedAt: string | null;
35
+ readonly active?: ServedVersionView | null;
36
+ };
37
+ /** The version the resolver serves while a draft is open beside it. */
38
+ export type ServedVersionView = {
39
+ readonly version: number;
40
+ readonly since: string;
34
41
  };
35
42
  export type VariableType = "text" | "link" | "money" | "date" | "list";
36
43
  export type VariableInfo = {
37
44
  readonly type: VariableType;
38
45
  readonly label?: string;
39
46
  readonly required?: boolean;
47
+ readonly item?: Readonly<Record<string, {
48
+ readonly type: VariableType;
49
+ readonly label?: string;
50
+ }>>;
40
51
  };
41
52
  export type ValidationWarning = {
42
53
  readonly code: string;
@@ -82,10 +93,22 @@ export type PdfPreviewResult = {
82
93
  readonly sizeBytes: number;
83
94
  readonly durationMs: number;
84
95
  };
96
+ export type TenantOverrideRow = {
97
+ readonly tenantId: string;
98
+ readonly tenantName: string;
99
+ readonly templateId: string;
100
+ readonly locale: string;
101
+ readonly status: "active" | "draft";
102
+ readonly version: number;
103
+ readonly changedAt: string;
104
+ readonly changedBy: string | null;
105
+ };
85
106
  export type TenantOverrides = {
86
107
  readonly activeOverrides: number;
87
108
  readonly draftOverrides: number;
88
109
  readonly totalTenants: number | null;
110
+ readonly rows: readonly TenantOverrideRow[];
111
+ readonly diff: TemplateDiff | null;
89
112
  };
90
113
  export type SaveResult = {
91
114
  readonly id: string;
@@ -0,0 +1 @@
1
+ export declare function useDebouncedValue<T>(value: T, delayMs: number): T;
@@ -0,0 +1,11 @@
1
+ import { useEffect, useState } from "react";
2
+ // Unlike useDeferredValue this waits for a quiet period, so a burst of
3
+ // keystrokes triggers one server round-trip instead of one per keystroke.
4
+ export function useDebouncedValue(value, delayMs) {
5
+ const [debounced, setDebounced] = useState(value);
6
+ useEffect(() => {
7
+ const timer = setTimeout(() => setDebounced(value), delayMs);
8
+ return () => clearTimeout(timer);
9
+ }, [value, delayMs]);
10
+ return debounced;
11
+ }
@@ -1,7 +1,7 @@
1
1
  import type { DocumentEditorHandle } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
2
- export type InsertField = "subject" | "previewText" | "body";
2
+ export type InsertField = "subject" | "previewText" | "headerChip" | "footerNote" | "body" | "title" | "author";
3
3
  export type InsertTargets = {
4
- readonly register: (field: InsertField) => (handle: DocumentEditorHandle) => void;
4
+ readonly register: (field: InsertField) => (handle: DocumentEditorHandle | null) => void;
5
5
  readonly markFocused: (field: InsertField) => () => void;
6
6
  readonly insertPlaceholder: (name: string) => void;
7
7
  readonly insertResource: (key: string) => void;
@@ -6,7 +6,10 @@ export function useInsertTargets() {
6
6
  const lastFocused = useRef("body");
7
7
  return {
8
8
  register: (field) => (handle) => {
9
- handles.current[field] = handle;
9
+ if (handle === null)
10
+ delete handles.current[field];
11
+ else
12
+ handles.current[field] = handle;
10
13
  },
11
14
  markFocused: (field) => () => {
12
15
  lastFocused.current = field;
@@ -1,6 +1,7 @@
1
1
  import type { MailDraft, TemplateRowView } from "./mail-types.js";
2
+ export type SaveOutcome = "saved" | "saved-not-published" | "failed";
2
3
  export type MailPersistence = {
3
- readonly save: (draft: MailDraft, publish: boolean) => Promise<boolean>;
4
+ readonly save: (draft: MailDraft, publish: boolean) => Promise<SaveOutcome>;
4
5
  readonly busy: boolean;
5
6
  readonly error: string | null;
6
7
  readonly clearError: () => void;
@@ -11,5 +12,4 @@ export declare function useMailPersistence(args: {
11
12
  readonly title: string;
12
13
  readonly isSystemAdmin: boolean;
13
14
  readonly row: TemplateRowView | null;
14
- readonly sender: unknown;
15
15
  }): MailPersistence;
@@ -21,7 +21,7 @@ export function useMailPersistence(args) {
21
21
  title: args.title,
22
22
  content: draft.body,
23
23
  contentFormat: "html",
24
- meta: buildMeta(draft, args.sender),
24
+ meta: buildMeta(draft),
25
25
  variableSchema: parseJsonRecord(args.row?.variableSchema),
26
26
  };
27
27
  const saved = args.isSystemAdmin
@@ -29,16 +29,16 @@ export function useMailPersistence(args) {
29
29
  : await upsertTenant.mutate({ ...base, status: publish ? "active" : "draft" });
30
30
  if (!saved.isSuccess) {
31
31
  setError(errorTextWithFieldIssues(saved.error, t));
32
- return false;
32
+ return "failed";
33
33
  }
34
34
  if (args.isSystemAdmin && publish) {
35
35
  const published = await publishSystem.mutate({ id: saved.data.id });
36
36
  if (!published.isSuccess) {
37
37
  setError(errorTextWithFieldIssues(published.error, t));
38
- return false;
38
+ return "saved-not-published";
39
39
  }
40
40
  }
41
- return true;
41
+ return "saved";
42
42
  };
43
43
  return {
44
44
  save,