@cosmicdrift/kumiko-document-designer 0.0.0-stage → 0.364.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 (126) hide show
  1. package/LICENSE +57 -0
  2. package/README.md +37 -2
  3. package/dist/constants.d.ts +27 -0
  4. package/dist/constants.js +37 -0
  5. package/dist/feature.d.ts +2 -0
  6. package/dist/feature.js +85 -0
  7. package/dist/i18n.d.ts +6 -0
  8. package/dist/i18n.js +764 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.js +2 -0
  11. package/dist/list-definition.d.ts +6 -0
  12. package/dist/list-definition.js +109 -0
  13. package/dist/web/cells.d.ts +10 -0
  14. package/dist/web/cells.js +87 -0
  15. package/dist/web/client-plugin.d.ts +12 -0
  16. package/dist/web/client-plugin.js +43 -0
  17. package/dist/web/compose-layout.d.ts +6 -0
  18. package/dist/web/compose-layout.js +13 -0
  19. package/dist/web/editor-header.d.ts +37 -0
  20. package/dist/web/editor-header.js +43 -0
  21. package/dist/web/editor-shell.d.ts +40 -0
  22. package/dist/web/editor-shell.js +36 -0
  23. package/dist/web/errors.d.ts +6 -0
  24. package/dist/web/errors.js +24 -0
  25. package/dist/web/field-issue-text.d.ts +4 -0
  26. package/dist/web/field-issue-text.js +12 -0
  27. package/dist/web/file-name.d.ts +2 -0
  28. package/dist/web/file-name.js +17 -0
  29. package/dist/web/i18n.d.ts +2 -0
  30. package/dist/web/i18n.js +146 -0
  31. package/dist/web/index.d.ts +6 -0
  32. package/dist/web/index.js +7 -0
  33. package/dist/web/layout-editor-body.d.ts +14 -0
  34. package/dist/web/layout-editor-body.js +187 -0
  35. package/dist/web/layout-editor.d.ts +2 -0
  36. package/dist/web/layout-editor.js +43 -0
  37. package/dist/web/layout-html.d.ts +9 -0
  38. package/dist/web/layout-html.js +93 -0
  39. package/dist/web/layout-meta.d.ts +29 -0
  40. package/dist/web/layout-meta.js +32 -0
  41. package/dist/web/layout-rails.d.ts +28 -0
  42. package/dist/web/layout-rails.js +100 -0
  43. package/dist/web/layout-types.d.ts +66 -0
  44. package/dist/web/layout-types.js +1 -0
  45. package/dist/web/layout-users.d.ts +4 -0
  46. package/dist/web/layout-users.js +11 -0
  47. package/dist/web/mail-dialogs.d.ts +24 -0
  48. package/dist/web/mail-dialogs.js +15 -0
  49. package/dist/web/mail-draft.d.ts +19 -0
  50. package/dist/web/mail-draft.js +154 -0
  51. package/dist/web/mail-edit-center.d.ts +19 -0
  52. package/dist/web/mail-edit-center.js +12 -0
  53. package/dist/web/mail-editor-body.d.ts +17 -0
  54. package/dist/web/mail-editor-body.js +209 -0
  55. package/dist/web/mail-editor.d.ts +2 -0
  56. package/dist/web/mail-editor.js +68 -0
  57. package/dist/web/mail-footer.d.ts +10 -0
  58. package/dist/web/mail-footer.js +43 -0
  59. package/dist/web/mail-left-rail.d.ts +23 -0
  60. package/dist/web/mail-left-rail.js +56 -0
  61. package/dist/web/mail-missing-locale.d.ts +10 -0
  62. package/dist/web/mail-missing-locale.js +45 -0
  63. package/dist/web/mail-origin-bar.d.ts +15 -0
  64. package/dist/web/mail-origin-bar.js +60 -0
  65. package/dist/web/mail-pdf-link-modal.d.ts +11 -0
  66. package/dist/web/mail-pdf-link-modal.js +54 -0
  67. package/dist/web/mail-preview-side.d.ts +19 -0
  68. package/dist/web/mail-preview-side.js +57 -0
  69. package/dist/web/mail-preview.d.ts +24 -0
  70. package/dist/web/mail-preview.js +54 -0
  71. package/dist/web/mail-right-rail.d.ts +19 -0
  72. package/dist/web/mail-right-rail.js +57 -0
  73. package/dist/web/mail-types.d.ts +117 -0
  74. package/dist/web/mail-types.js +1 -0
  75. package/dist/web/mail-upload-modal.d.ts +16 -0
  76. package/dist/web/mail-upload-modal.js +56 -0
  77. package/dist/web/mail-versions.d.ts +6 -0
  78. package/dist/web/mail-versions.js +43 -0
  79. package/dist/web/origin-bar.d.ts +22 -0
  80. package/dist/web/origin-bar.js +104 -0
  81. package/dist/web/overrides-drawer.d.ts +10 -0
  82. package/dist/web/overrides-drawer.js +95 -0
  83. package/dist/web/overview-row.d.ts +23 -0
  84. package/dist/web/overview-row.js +48 -0
  85. package/dist/web/page-canvas.d.ts +19 -0
  86. package/dist/web/page-canvas.js +48 -0
  87. package/dist/web/pdf-footer.d.ts +13 -0
  88. package/dist/web/pdf-footer.js +34 -0
  89. package/dist/web/pdf-left-rail.d.ts +9 -0
  90. package/dist/web/pdf-left-rail.js +16 -0
  91. package/dist/web/pdf-meta.d.ts +21 -0
  92. package/dist/web/pdf-meta.js +95 -0
  93. package/dist/web/pdf-preview.d.ts +4 -0
  94. package/dist/web/pdf-preview.js +112 -0
  95. package/dist/web/pdf-template-editor.d.ts +8 -0
  96. package/dist/web/pdf-template-editor.js +340 -0
  97. package/dist/web/preview-record-picker.d.ts +12 -0
  98. package/dist/web/preview-record-picker.js +7 -0
  99. package/dist/web/rail.d.ts +30 -0
  100. package/dist/web/rail.js +22 -0
  101. package/dist/web/relative-time.d.ts +1 -0
  102. package/dist/web/relative-time.js +18 -0
  103. package/dist/web/settings-panel.d.ts +23 -0
  104. package/dist/web/settings-panel.js +36 -0
  105. package/dist/web/status-label.d.ts +14 -0
  106. package/dist/web/status-label.js +26 -0
  107. package/dist/web/template-rows.d.ts +9 -0
  108. package/dist/web/template-rows.js +34 -0
  109. package/dist/web/types.d.ts +126 -0
  110. package/dist/web/types.js +3 -0
  111. package/dist/web/use-insert-targets.d.ts +9 -0
  112. package/dist/web/use-insert-targets.js +17 -0
  113. package/dist/web/use-mail-persistence.d.ts +14 -0
  114. package/dist/web/use-mail-persistence.js +49 -0
  115. package/dist/web/use-overview-rows.d.ts +7 -0
  116. package/dist/web/use-overview-rows.js +19 -0
  117. package/dist/web/use-own-address.d.ts +5 -0
  118. package/dist/web/use-own-address.js +14 -0
  119. package/dist/web/use-settled-value.d.ts +2 -0
  120. package/dist/web/use-settled-value.js +18 -0
  121. package/dist/web/use-test-mail.d.ts +15 -0
  122. package/dist/web/use-test-mail.js +27 -0
  123. package/dist/web/used-by-label.d.ts +9 -0
  124. package/dist/web/used-by-label.js +32 -0
  125. package/package.json +63 -4
  126. package/src/changes.json +44 -0
@@ -0,0 +1,340 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // @runtime client
3
+ import { TemplateResolverHandlers, TemplateResolverQueries, } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
4
+ import { useDispatcher, usePrimitives, useQuery, useTranslation, useUserRoles, } from "@cosmicdrift/kumiko-renderer";
5
+ import { RendererPuppeteerClientQueries } from "@cosmicdrift/kumiko-renderer-puppeteer-client/qualified-names";
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";
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";
15
+ import { OverridesDrawer } from "./overrides-drawer.js";
16
+ import { PageCanvas } from "./page-canvas.js";
17
+ import { pdfFooterMessage } from "./pdf-footer.js";
18
+ import { PdfResourceSection, visibleSystemPlaceholders } from "./pdf-left-rail.js";
19
+ import { EMPTY_PDF_META, parsePdfMeta, serializePdfMeta } from "./pdf-meta.js";
20
+ import { PdfPreview } from "./pdf-preview.js";
21
+ import { SettingsPanel } from "./settings-panel.js";
22
+ import { draftBesideActiveSentence } from "./status-label.js";
23
+ import { localesOf, parseJsonObject, pickRow, resolveLocale } from "./template-rows.js";
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
+ const SETTLE_MS = 500;
28
+ const NO_VARIABLES = {};
29
+ // Opening the preview prints the page without a click; a failed print is not retried for the same draft.
30
+ function RunOnMount({ run }) {
31
+ useEffect(() => {
32
+ run();
33
+ }, [run]);
34
+ return null;
35
+ }
36
+ const draftFromDetail = (detail) => ({
37
+ content: detail.content,
38
+ meta: parsePdfMeta(detail.meta),
39
+ });
40
+ const snapshotOf = (draft) => JSON.stringify({ content: draft.content, meta: serializePdfMeta(draft.meta) });
41
+ export function PdfTemplateEditor({ slug, locale: requestedLocale, onLocaleChange, }) {
42
+ const t = useTranslation();
43
+ const dispatcher = useDispatcher();
44
+ const { Button, Banner, Dialog } = usePrimitives();
45
+ const insert = useInsertTargets();
46
+ const overview = useOverviewRows(DOCUMENT_PDF_KIND);
47
+ const roles = useUserRoles();
48
+ const scope = roles?.includes("SystemAdmin") === true ? "system" : "tenant";
49
+ const rowsQuery = useQuery(TemplateResolverQueries.list, {
50
+ kind: DOCUMENT_PDF_KIND,
51
+ });
52
+ const rows = rowsQuery.data ?? [];
53
+ const locales = localesOf(rows, slug);
54
+ const locale = resolveLocale(requestedLocale, locales);
55
+ const row = pickRow(rows, slug, locale, scope);
56
+ const detailQuery = useQuery(TemplateResolverQueries.findById, { id: row?.id ?? "" }, { enabled: row !== undefined });
57
+ const detail = detailQuery.data !== null &&
58
+ detailQuery.data?.slug === slug &&
59
+ detailQuery.data.locale === locale
60
+ ? detailQuery.data
61
+ : null;
62
+ const [state, setState] = useState(null);
63
+ if (detail !== null && (state === null || state.key !== detail.id)) {
64
+ const draft = draftFromDetail(detail);
65
+ setState({ key: detail.id, draft, settled: draft, snapshot: snapshotOf(draft) });
66
+ }
67
+ const current = state !== null && detail !== null && state.key === detail.id ? state : null;
68
+ const settleTimer = useRef(null);
69
+ // A pending settle must not fire into an unmounted editor.
70
+ useEffect(() => () => {
71
+ if (settleTimer.current !== null)
72
+ clearTimeout(settleTimer.current);
73
+ }, []);
74
+ const [overridesOpen, setOverridesOpen] = useState(false);
75
+ const [draftEpoch, setDraftEpoch] = useState(0);
76
+ const [mode, setMode] = useState("edit");
77
+ const [busy, setBusy] = useState(null);
78
+ const [actionError, setActionError] = useState(null);
79
+ const [generated, setGenerated] = useState(null);
80
+ const autoPrintedFor = useRef(null);
81
+ const [pendingLocale, setPendingLocale] = useState(null);
82
+ const variableSchema = useMemo(() => parseJsonObject(detail?.variableSchema ?? null), [detail?.variableSchema]);
83
+ const settledMeta = current === null ? null : serializePdfMeta(current.settled.meta);
84
+ const validationQuery = useQuery(TemplateResolverQueries.validate, {
85
+ slug,
86
+ kind: DOCUMENT_PDF_KIND,
87
+ contentFormat: "html",
88
+ content: current?.settled.content ?? "",
89
+ meta: settledMeta ?? {},
90
+ variableSchema,
91
+ locale,
92
+ scope,
93
+ }, { enabled: current !== null });
94
+ const validation = validationQuery.data;
95
+ const layoutSlug = current?.settled.meta.layoutSlug ?? "";
96
+ const previewQuery = useQuery(TemplateResolverQueries.preview, {
97
+ slug,
98
+ kind: DOCUMENT_PDF_KIND,
99
+ locale,
100
+ content: detail?.content ?? "",
101
+ meta: layoutSlug === "" ? {} : { layoutSlug },
102
+ variableSchema,
103
+ }, { enabled: detail !== null });
104
+ const layout = previewQuery.error === null ? (previewQuery.data?.layout ?? null) : null;
105
+ const layouts = useOverviewRows(PDF_LAYOUT_KIND);
106
+ // The built-in "logo" resource of a layout is the tenant's branding logo.
107
+ const branding = useQuery(BRANDING_QUERY_QN, {});
108
+ const brandingLogoFile = branding.data?.logoFile ?? "";
109
+ const statusQuery = useQuery(RendererPuppeteerClientQueries.status, {});
110
+ const pdfAvailable = statusQuery.error === null && statusQuery.data?.available === true;
111
+ const unavailableMessage = statusQuery.data?.available === false ? (statusQuery.data.message ?? "") : "";
112
+ const showOrigin = scope === "system";
113
+ const overridesQuery = useQuery(TemplateResolverQueries.tenantOverrides, { slug, kind: DOCUMENT_PDF_KIND, locale }, { enabled: showOrigin && detail !== null });
114
+ const variables = validation?.variables ?? NO_VARIABLES;
115
+ const systemVariables = useMemo(() => visibleSystemPlaceholders(validation?.systemPlaceholders ?? []), [validation?.systemPlaceholders]);
116
+ const systemLabels = useMemo(() => systemLabelMap(t, systemVariables), [t, systemVariables]);
117
+ const variableLabels = useMemo(() => ({ ...systemLabels, ...variableLabelMap(variables) }), [systemLabels, variables]);
118
+ const variableNames = useMemo(() => Object.keys(variables), [variables]);
119
+ const linkedResources = detail?.linkedResources ?? null;
120
+ // The editor shows resource images from the stored copies; no extra request is made here.
121
+ const resourcePreviews = useMemo(() => Object.fromEntries(Object.entries(parseJsonObject(linkedResources)).flatMap(([key, fileRefId]) => typeof fileRefId === "string"
122
+ ? [[key, { src: `/api/files/${encodeURIComponent(fileRefId)}`, caption: key }]]
123
+ : [])), [linkedResources]);
124
+ if (rowsQuery.loading && rowsQuery.data === null)
125
+ return _jsx(LoadingState, {});
126
+ if (rowsQuery.error !== null)
127
+ return _jsx(ErrorState, { error: rowsQuery.error });
128
+ if (row === undefined)
129
+ return _jsx(Banner, { variant: "info", children: t("documentDesigner.notFound", { slug }) });
130
+ if (current === null || detail === null) {
131
+ return detailQuery.error !== null ? _jsx(ErrorState, { error: detailQuery.error }) : _jsx(LoadingState, {});
132
+ }
133
+ const dirty = snapshotOf(current.draft) !== current.snapshot;
134
+ const generatedFresh = generated !== null && generated.forSnapshot === snapshotOf(current.draft);
135
+ function updateDraft(next) {
136
+ setState((previous) => (previous === null ? previous : { ...previous, draft: next }));
137
+ if (settleTimer.current !== null)
138
+ clearTimeout(settleTimer.current);
139
+ settleTimer.current = setTimeout(() => {
140
+ setState((previous) => previous === null ? previous : { ...previous, settled: previous.draft });
141
+ }, SETTLE_MS);
142
+ }
143
+ function resetDraft(source) {
144
+ const draft = draftFromDetail(source);
145
+ if (settleTimer.current !== null)
146
+ clearTimeout(settleTimer.current);
147
+ setState({ key: source.id, draft, settled: draft, snapshot: snapshotOf(draft) });
148
+ setDraftEpoch((epoch) => epoch + 1);
149
+ setActionError(null);
150
+ }
151
+ async function reload() {
152
+ await Promise.all([rowsQuery.refetch(), detailQuery.refetch(), validationQuery.refetch()]);
153
+ }
154
+ async function persist() {
155
+ const draft = current?.draft ?? { content: "", meta: EMPTY_PDF_META };
156
+ const parentTemplateId = detail?.scope === "system" ? detail.id : detail?.parentTemplateId;
157
+ const result = await dispatcher.write(scope === "system"
158
+ ? TemplateResolverHandlers.upsertSystem
159
+ : TemplateResolverHandlers.upsertTenant, {
160
+ slug,
161
+ kind: DOCUMENT_PDF_KIND,
162
+ locale,
163
+ content: draft.content,
164
+ contentFormat: "html",
165
+ variableSchema,
166
+ meta: serializePdfMeta(draft.meta),
167
+ ...(scope === "tenant" && parentTemplateId != null && { parentTemplateId }),
168
+ });
169
+ if (!result.isSuccess) {
170
+ setActionError(describeError(t, result.error));
171
+ return null;
172
+ }
173
+ setState((previous) => previous === null ? previous : { ...previous, snapshot: snapshotOf(draft) });
174
+ return result.data;
175
+ }
176
+ async function save() {
177
+ setBusy("save");
178
+ setActionError(null);
179
+ const saved = await persist();
180
+ if (saved !== null)
181
+ await reload();
182
+ setBusy(null);
183
+ return saved !== null;
184
+ }
185
+ async function publish() {
186
+ setBusy("publish");
187
+ setActionError(null);
188
+ // A system save activates immediately; a tenant row needs a save first when it
189
+ // does not exist yet or carries unsaved edits, then an explicit publish.
190
+ const needsSave = dirty || detail?.scope === "system";
191
+ const saved = needsSave ? await persist() : null;
192
+ const saveFailed = needsSave && saved === null;
193
+ // upsertSystem already activates, so only a tenant row needs the explicit publish.
194
+ const alreadyActivatedBySave = scope === "system" && saved !== null;
195
+ const templateId = saved?.id ?? detail?.id ?? null;
196
+ if (templateId !== null && !saveFailed && !alreadyActivatedBySave) {
197
+ const result = await dispatcher.write(scope === "system"
198
+ ? TemplateResolverHandlers.publishSystem
199
+ : TemplateResolverHandlers.publish, { id: templateId });
200
+ if (!result.isSuccess && !isAlreadyActive(result.error))
201
+ setActionError(describeError(t, result.error));
202
+ }
203
+ await reload();
204
+ setBusy(null);
205
+ }
206
+ async function generatePdf() {
207
+ if (current === null)
208
+ return;
209
+ setMode("preview");
210
+ setBusy("pdf");
211
+ setActionError(null);
212
+ const forSnapshot = snapshotOf(current.draft);
213
+ const result = await dispatcher.query(RendererPuppeteerClientQueries.previewPdf, {
214
+ slug,
215
+ locale,
216
+ content: current.draft.content,
217
+ meta: serializePdfMeta(current.draft.meta),
218
+ variableSchema,
219
+ });
220
+ if (result.isSuccess)
221
+ setGenerated({ forSnapshot, result: result.data });
222
+ else
223
+ setActionError(describeError(t, result.error));
224
+ setBusy(null);
225
+ }
226
+ function printOnce() {
227
+ if (current === null || autoPrintedFor.current === snapshotOf(current.draft))
228
+ return;
229
+ autoPrintedFor.current = snapshotOf(current.draft);
230
+ void generatePdf();
231
+ }
232
+ async function uploadResource(file) {
233
+ const sourceFileRefId = await uploadFileRef(file);
234
+ const result = await dispatcher.write(TemplateResolverHandlers.resourceUpload, {
235
+ templateId: detail?.id,
236
+ sourceFileRefId,
237
+ });
238
+ if (!result.isSuccess)
239
+ throw new Error(describeError(t, result.error));
240
+ await detailQuery.refetch();
241
+ }
242
+ function requestLocale(next) {
243
+ if (next === locale)
244
+ return;
245
+ if (dirty)
246
+ setPendingLocale(next);
247
+ else
248
+ onLocaleChange?.(next);
249
+ }
250
+ const canPublish = validation?.canPublish === true;
251
+ const layoutSlugShown = layout?.slug ?? DEFAULT_LAYOUT_SLUG;
252
+ const layoutName = layouts.rows.find((row) => row.slug === layoutSlugShown)?.title ?? layoutSlugShown;
253
+ const layoutResourceSrc = (key) => key === "logo" && brandingLogoFile !== "" ? fileUrl(brandingLogoFile) : undefined;
254
+ const placeholderSource = validation === undefined || validation === null
255
+ ? null
256
+ : {
257
+ ...validation,
258
+ systemPlaceholders: visibleSystemPlaceholders(validation.systemPlaceholders),
259
+ };
260
+ const layoutFills = (name) => layout !== null &&
261
+ (layout.html.includes(`data-variable="${name}"`) || layout.html.includes(`{{${name}}}`));
262
+ const overrideCount = (overridesQuery.data?.activeOverrides ?? 0) + (overridesQuery.data?.draftOverrides ?? 0);
263
+ const resourceKeys = Object.keys(parseJsonObject(detail.linkedResources));
264
+ const overviewRow = overview.rows.find((candidate) => candidate.slug === slug);
265
+ const title = overviewRow?.title ?? slug;
266
+ const systemVersion = overviewRow?.systemVersion ?? null;
267
+ const vocabulary = {
268
+ names: variableNames,
269
+ labels: variableLabels,
270
+ system: systemVariables,
271
+ };
272
+ const footer = validation === undefined || validation === null
273
+ ? null
274
+ : pdfFooterMessage(t, validation, generated === null
275
+ ? null
276
+ : {
277
+ pages: generated.result.pageCount,
278
+ sizeBytes: generated.result.sizeBytes,
279
+ durationMs: generated.result.durationMs,
280
+ });
281
+ const showOverrideLinks = showOrigin && detail.scope === "system";
282
+ const originText = showOverrideLinks
283
+ ? overridesQuery.data?.totalTenants == null
284
+ ? t("documentDesigner.originBar.noTotal", { count: overrideCount })
285
+ : t("documentDesigner.originBar.withTotal", {
286
+ count: overrideCount,
287
+ total: overridesQuery.data.totalTenants,
288
+ })
289
+ : detail.scope === "system"
290
+ ? t("documentDesigner.mail.origin.system", { version: detail.version })
291
+ : systemVersion === null
292
+ ? t("documentDesigner.mail.origin.tenantOnly")
293
+ : t("documentDesigner.mail.origin.overrides", { version: systemVersion });
294
+ const besideActiveSentence = draftBesideActiveSentence(t, detail);
295
+ const pdfUnavailableNote = !pdfAvailable && statusQuery.loading === false;
296
+ return (_jsxs(EditorFrame, { testId: "pdf-template-editor", children: [_jsx(EditorHeader, { title: title, stored: { status: detail.status, version: detail.version }, actions: {
297
+ busy: busy === "save",
298
+ canSave: busy === null && validation?.canSave !== false,
299
+ canPublish: busy === null && canPublish,
300
+ onSave: () => void save(),
301
+ onPublish: () => void publish(),
302
+ }, 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 === ""
303
+ ? t("documentDesigner.pdfUnavailableNoReason")
304
+ : 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"
305
+ ? t("documentDesigner.preview.loading")
306
+ : 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) => {
307
+ if (!open)
308
+ setPendingLocale(null);
309
+ }, title: t("documentDesigner.localeDialog.title"), description: t("documentDesigner.localeDialog.description"), confirmLabel: t("documentDesigner.localeDialog.save"), cancelLabel: t("documentDesigner.localeDialog.cancel"), onConfirm: async () => {
310
+ const target = pendingLocale;
311
+ if (target !== null && (await save()))
312
+ onLocaleChange?.(target);
313
+ setPendingLocale(null);
314
+ }, children: _jsx(Button, { variant: "danger-ghost", onClick: () => {
315
+ const target = pendingLocale;
316
+ setPendingLocale(null);
317
+ resetDraft(detail);
318
+ if (target !== null)
319
+ onLocaleChange?.(target);
320
+ }, children: t("documentDesigner.localeDialog.discard") }) })] }));
321
+ async function exportBundle() {
322
+ const result = await dispatcher.query(TemplateResolverQueries.exportBundle, {
323
+ slug,
324
+ kind: DOCUMENT_PDF_KIND,
325
+ scope: "system",
326
+ });
327
+ if (!result.isSuccess) {
328
+ setActionError(describeError(t, result.error));
329
+ return;
330
+ }
331
+ const url = URL.createObjectURL(new Blob([JSON.stringify(result.data, null, 2)], { type: "application/json" }));
332
+ const anchor = document.createElement("a");
333
+ anchor.href = url;
334
+ // Slugs are namespaced with ":", which browsers rewrite in download names
335
+ // on their own; replacing it here keeps the file name stable everywhere.
336
+ anchor.download = `${slug.replace(/[\\/:*?"<>|]/g, "_")}.bundle.json`;
337
+ anchor.click();
338
+ URL.revokeObjectURL(url);
339
+ }
340
+ }
@@ -0,0 +1,12 @@
1
+ import { type ReactNode } from "react";
2
+ export type PreviewRecordPickerProps = {
3
+ readonly slug: string;
4
+ readonly locale: string;
5
+ readonly onPick: (values: Readonly<Record<string, unknown>>) => void;
6
+ };
7
+ export type PreviewRecordPicker = {
8
+ readonly label: string;
9
+ readonly renderPicker: (props: PreviewRecordPickerProps) => ReactNode;
10
+ };
11
+ export declare const PreviewRecordPickerProvider: import("react").Provider<PreviewRecordPicker | null>;
12
+ export declare function usePreviewRecordPicker(): PreviewRecordPicker | null;
@@ -0,0 +1,7 @@
1
+ // @runtime client
2
+ import { createContext, useContext } from "react";
3
+ const PreviewRecordPickerContext = createContext(null);
4
+ export const PreviewRecordPickerProvider = PreviewRecordPickerContext.Provider;
5
+ export function usePreviewRecordPicker() {
6
+ return useContext(PreviewRecordPickerContext);
7
+ }
@@ -0,0 +1,30 @@
1
+ import type { ReactNode } from "react";
2
+ export declare function RailSection({ title, action, testId, children, }: {
3
+ readonly title: string;
4
+ readonly action?: ReactNode;
5
+ readonly testId?: string;
6
+ readonly children: ReactNode;
7
+ }): ReactNode;
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, }: {
17
+ readonly symbol?: string;
18
+ readonly label: string;
19
+ readonly required?: boolean;
20
+ readonly trailing?: ReactNode;
21
+ readonly trailingTone?: keyof typeof TRAILING_TONE;
22
+ readonly action?: ReactNode;
23
+ readonly bad?: boolean;
24
+ readonly testId?: string;
25
+ readonly onPress?: () => void;
26
+ }): ReactNode;
27
+ export declare function RailHint({ children }: {
28
+ readonly children: ReactNode;
29
+ }): ReactNode;
30
+ export {};
@@ -0,0 +1,22 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // @runtime client
3
+ import { cn } from "@cosmicdrift/kumiko-renderer-web";
4
+ export function RailSection({ title, action, testId, 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] }));
6
+ }
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 })] }));
19
+ }
20
+ export function RailHint({ children }) {
21
+ return _jsx("p", { className: "px-3.5 pt-1 pb-2 text-xs text-muted-foreground", children: children });
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
+ }
@@ -0,0 +1,23 @@
1
+ import type { ReactNode } from "react";
2
+ import type { EditorVocabulary } from "./mail-edit-center.js";
3
+ import type { OverviewRowView } from "./overview-row.js";
4
+ import { type PdfMetaDraft } from "./pdf-meta.js";
5
+ import { type TemplateStatus } from "./types.js";
6
+ import type { InsertTargets } from "./use-insert-targets.js";
7
+ export type VersionInfo = {
8
+ readonly id: string;
9
+ readonly version: number;
10
+ readonly status: TemplateStatus;
11
+ };
12
+ export type SettingsPanelProps = {
13
+ readonly meta: PdfMetaDraft;
14
+ readonly onMetaChange: (next: PdfMetaDraft) => void;
15
+ readonly layouts: readonly OverviewRowView[];
16
+ readonly locale: string;
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;
22
+ };
23
+ export declare function SettingsPanel({ meta, onMetaChange, layouts, locale, version, vocabulary, insert, draftKey, }: SettingsPanelProps): ReactNode;
@@ -0,0 +1,36 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // @runtime client
3
+ import { PDF_ORIENTATIONS, PDF_PAGE_FORMATS, PlaceholderLineEditor, } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
4
+ import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
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";
11
+ const SIDE_LABEL_KEYS = {
12
+ top: "documentDesigner.settings.marginTop",
13
+ right: "documentDesigner.settings.marginRight",
14
+ bottom: "documentDesigner.settings.marginBottom",
15
+ left: "documentDesigner.settings.marginLeft",
16
+ };
17
+ export function SettingsPanel({ meta, onMetaChange, layouts, locale, version, vocabulary, insert, draftKey, }) {
18
+ const t = useTranslation();
19
+ const { Field, Input, Text } = usePrimitives();
20
+ const patch = (changes) => onMetaChange({ ...meta, ...changes });
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 })] }));
36
+ }
@@ -0,0 +1,14 @@
1
+ import type { StatusBadgeTone } from "@cosmicdrift/kumiko-renderer-web";
2
+ import type { OverviewRowView } from "./overview-row.js";
3
+ type Translate = (key: string, params?: Readonly<Record<string, unknown>>) => string;
4
+ export declare function statusTone(status: OverviewRowView["status"]): StatusBadgeTone;
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;
13
+ export declare function asRowStatus(status: string): OverviewRowView["status"];
14
+ export {};
@@ -0,0 +1,26 @@
1
+ const STATUS_TONE = {
2
+ active: "ok",
3
+ draft: "muted",
4
+ archived: "muted",
5
+ };
6
+ export function statusTone(status) {
7
+ return STATUS_TONE[status];
8
+ }
9
+ export function statusLabel(t, status, version) {
10
+ if (status === "archived")
11
+ return t("documentDesigner.status.archived");
12
+ return t(status === "active"
13
+ ? "documentDesigner.status.activeVersion"
14
+ : "documentDesigner.status.draftVersion", { version });
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
+ }
24
+ export function asRowStatus(status) {
25
+ return status === "active" || status === "archived" ? status : "draft";
26
+ }
@@ -0,0 +1,9 @@
1
+ import type { TemplateListRow, TemplateScope } from "./types.js";
2
+ /**
3
+ * The row the editor works on. A tenant sees its own customisation, or the
4
+ * system row as the starting point for one; a SystemAdmin edits the system row.
5
+ */
6
+ export declare function pickRow(rows: readonly TemplateListRow[], slug: string, locale: string, scope: TemplateScope): TemplateListRow | undefined;
7
+ export declare function localesOf(rows: readonly TemplateListRow[], slug: string): readonly string[];
8
+ export declare function resolveLocale(requested: string | undefined, available: readonly string[]): string;
9
+ export declare function parseJsonObject(raw: string | null): Record<string, unknown>;
@@ -0,0 +1,34 @@
1
+ // @runtime client
2
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
3
+ /**
4
+ * The row the editor works on. A tenant sees its own customisation, or the
5
+ * system row as the starting point for one; a SystemAdmin edits the system row.
6
+ */
7
+ export function pickRow(rows, slug, locale, scope) {
8
+ const candidates = rows.filter((row) => row.slug === slug && row.locale === locale);
9
+ if (scope === "system")
10
+ return candidates.find((row) => row.scope === "system");
11
+ return (candidates.find((row) => row.scope === "tenant") ??
12
+ candidates.find((row) => row.scope === "system"));
13
+ }
14
+ export function localesOf(rows, slug) {
15
+ return [...new Set(rows.filter((row) => row.slug === slug).map((row) => row.locale))].sort();
16
+ }
17
+ export function resolveLocale(requested, available) {
18
+ if (requested !== undefined && requested !== "")
19
+ return requested;
20
+ if (available.includes("de") || available.length === 0)
21
+ return "de";
22
+ return available[0] ?? "de";
23
+ }
24
+ export function parseJsonObject(raw) {
25
+ if (raw === null)
26
+ return {};
27
+ try {
28
+ const parsed = JSON.parse(raw);
29
+ return isPlainObject(parsed) ? parsed : {};
30
+ }
31
+ catch {
32
+ return {};
33
+ }
34
+ }