@cosmicdrift/kumiko-document-designer 0.389.0 → 0.390.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.
- package/dist/web/layout-editor-body.d.ts +3 -14
- package/dist/web/layout-editor-body.js +25 -172
- package/dist/web/layout-editor-center.d.ts +7 -0
- package/dist/web/layout-editor-center.js +23 -0
- package/dist/web/layout-editor-logic.d.ts +97 -0
- package/dist/web/layout-editor-logic.js +143 -0
- package/dist/web/layout-editor-paper.d.ts +4 -0
- package/dist/web/layout-editor-paper.js +24 -0
- package/dist/web/mail-editor-body.d.ts +4 -16
- package/dist/web/mail-editor-body.js +27 -218
- package/dist/web/mail-editor-logic.d.ts +70 -0
- package/dist/web/mail-editor-logic.js +81 -0
- package/dist/web/mail-editor-model.d.ts +105 -0
- package/dist/web/mail-editor-model.js +114 -0
- package/dist/web/mail-editor-render.d.ts +11 -0
- package/dist/web/mail-editor-render.js +51 -0
- package/dist/web/mail-preview-render.d.ts +5 -0
- package/dist/web/mail-preview-render.js +22 -0
- package/dist/web/pdf-editor-actions.d.ts +27 -0
- package/dist/web/pdf-editor-actions.js +146 -0
- package/dist/web/pdf-editor-chrome.d.ts +6 -0
- package/dist/web/pdf-editor-chrome.js +60 -0
- package/dist/web/pdf-editor-context.d.ts +22 -0
- package/dist/web/pdf-editor-context.js +1 -0
- package/dist/web/pdf-editor-draft.d.ts +23 -0
- package/dist/web/pdf-editor-draft.js +33 -0
- package/dist/web/pdf-editor-panes.d.ts +6 -0
- package/dist/web/pdf-editor-panes.js +40 -0
- package/dist/web/pdf-editor-view.d.ts +52 -0
- package/dist/web/pdf-editor-view.js +89 -0
- package/dist/web/pdf-template-editor.d.ts +1 -1
- package/dist/web/pdf-template-editor.js +40 -342
- package/dist/web/use-layout-editor-state.d.ts +62 -0
- package/dist/web/use-layout-editor-state.js +155 -0
- package/dist/web/use-mail-editor-parts.d.ts +42 -0
- package/dist/web/use-mail-editor-parts.js +74 -0
- package/dist/web/use-mail-editor-state.d.ts +89 -0
- package/dist/web/use-mail-editor-state.js +55 -0
- package/dist/web/use-pdf-editor-state.d.ts +77 -0
- package/dist/web/use-pdf-editor-state.js +161 -0
- package/package.json +6 -6
- package/src/changes.json +18 -0
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
export declare function LayoutEditorBody({ kind, slug, isSystemAdmin, source, overviewRows, usedByCount, overrides, onKindChange, onReload, }: {
|
|
5
|
-
readonly kind: LayoutKind;
|
|
6
|
-
readonly slug: string;
|
|
7
|
-
readonly isSystemAdmin: boolean;
|
|
8
|
-
readonly source: LayoutSourceView;
|
|
9
|
-
readonly overviewRows: readonly OverviewRowView[];
|
|
10
|
-
readonly usedByCount: number;
|
|
11
|
-
readonly overrides: TenantOverrideCounts | null;
|
|
12
|
-
readonly onKindChange: (kind: LayoutKind) => void;
|
|
13
|
-
readonly onReload: () => Promise<void>;
|
|
14
|
-
}): ReactNode;
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { type LayoutEditorBodyProps } from "./use-layout-editor-state.ts";
|
|
3
|
+
export declare function LayoutEditorBody(props: LayoutEditorBodyProps): ReactNode;
|
|
@@ -1,187 +1,40 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { pluralKey } from "@cosmicdrift/kumiko-renderer/headless";
|
|
5
|
-
import { cn, ModeSwitch, TextareaField } from "@cosmicdrift/kumiko-renderer/web";
|
|
6
|
-
import { useState } from "react";
|
|
7
|
-
import { composeLayoutHtml } from "./compose-layout.js";
|
|
8
|
-
import { BRANDING_QUERY_QN } from "./constants.js";
|
|
2
|
+
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { ModeSwitch } from "@cosmicdrift/kumiko-renderer/web";
|
|
9
4
|
import { EditorHeader } from "./editor-header.js";
|
|
10
5
|
import { EditorBody, EditorFooter, EditorFrame } from "./editor-shell.js";
|
|
11
|
-
import {
|
|
12
|
-
import { appearanceOf, isHexColor, metaToSave, } from "./layout-meta.js";
|
|
6
|
+
import { renderCenter } from "./layout-editor-center.js";
|
|
13
7
|
import { AppearanceRail, BrandingRail, MjmlSourceRail, PlaceholderRail, RendererRail, UsedByRail, } from "./layout-rails.js";
|
|
14
|
-
import { systemLabelMap } from "./mail-draft.js";
|
|
15
8
|
import { OriginBar } from "./origin-bar.js";
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const REGION_EDITOR_IDS = {
|
|
21
|
-
header: "document-designer-region-header",
|
|
22
|
-
signature: "document-designer-region-signature",
|
|
23
|
-
footer: "document-designer-region-footer",
|
|
24
|
-
};
|
|
25
|
-
const PAPER_WIDTH_CLASS = {
|
|
26
|
-
560: "max-w-[560px]",
|
|
27
|
-
600: "max-w-[600px]",
|
|
28
|
-
640: "max-w-[640px]",
|
|
29
|
-
};
|
|
30
|
-
function issueText(t, issue) {
|
|
31
|
-
const translated = t(issue.i18nKey, issue.params);
|
|
32
|
-
return translated === issue.i18nKey ? issue.code : translated;
|
|
9
|
+
import { useLayoutEditorState, } from "./use-layout-editor-state.js";
|
|
10
|
+
function renderLeftRail(props, m) {
|
|
11
|
+
const { branding } = m;
|
|
12
|
+
return (_jsxs(_Fragment, { children: [_jsx(BrandingRail, { data: branding.data, unavailable: branding.error !== null || (branding.data === null && !branding.loading) }), _jsx(PlaceholderRail, { validation: m.checked }), _jsx(RendererRail, { kind: props.kind })] }));
|
|
33
13
|
}
|
|
34
|
-
function
|
|
35
|
-
return (
|
|
36
|
-
a.font === b.font &&
|
|
37
|
-
a.outerBackground === b.outerBackground &&
|
|
38
|
-
a.accentLine === b.accentLine &&
|
|
39
|
-
a.buttonStyle === b.buttonStyle);
|
|
14
|
+
function renderRightRail(props, m) {
|
|
15
|
+
return (_jsxs(_Fragment, { children: [m.isMail && (_jsx(AppearanceRail, { appearance: m.appearance, onChange: m.setAppearance, fromMjml: m.appearanceFromMjml })), m.isMail && !m.mjmlMode && _jsx(MjmlSourceRail, { mjmlSource: props.source.mjmlSource }), _jsx(UsedByRail, { layoutSlug: props.slug, kind: props.kind, rows: props.overviewRows })] }));
|
|
40
16
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
17
|
+
function renderKindSwitch(t, props, m) {
|
|
18
|
+
return (_jsx(ModeSwitch, { value: props.kind, onChange: m.requestKind, ariaLabel: t("documentDesigner.editor.kindSwitch"), testId: "document-designer-kind-switch", options: [
|
|
19
|
+
{ value: "mail-layout", label: t("documentDesigner.editor.kind.mail") },
|
|
20
|
+
{ value: "pdf-layout", label: t("documentDesigner.editor.kind.pdf") },
|
|
21
|
+
] }));
|
|
44
22
|
}
|
|
45
|
-
export function LayoutEditorBody(
|
|
23
|
+
export function LayoutEditorBody(props) {
|
|
24
|
+
const { kind, isSystemAdmin, source, overrides, onKindChange, onReload } = props;
|
|
46
25
|
const t = useTranslation();
|
|
47
26
|
const { Banner, Dialog } = usePrimitives();
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const [tab, setTab] = useState(showMjmlTab && source.effectiveFormat === "mjml" ? "mjml" : "draft");
|
|
51
|
-
const [header, setHeader] = useState(source.regions.header);
|
|
52
|
-
const [signature, setSignature] = useState(source.regions.signature);
|
|
53
|
-
const [footer, setFooter] = useState(source.regions.footer);
|
|
54
|
-
const [mjml, setMjml] = useState(source.mjmlSource ?? "");
|
|
55
|
-
const storedAppearance = appearanceOf(source.meta);
|
|
56
|
-
const [appearance, setAppearance] = useState(storedAppearance);
|
|
57
|
-
const [activeRegion, setActiveRegion] = useState("header");
|
|
58
|
-
const [toolbarHost, setToolbarHost] = useState(null);
|
|
59
|
-
const [pendingKind, setPendingKind] = useState(null);
|
|
60
|
-
const [saveError, setSaveError] = useState(null);
|
|
61
|
-
const upsertTenant = useMutation(TemplateResolverHandlers.upsertTenant);
|
|
62
|
-
const upsertSystem = useMutation(TemplateResolverHandlers.upsertSystem);
|
|
63
|
-
const publishSystem = useMutation(TemplateResolverHandlers.publishSystem);
|
|
64
|
-
const branding = useQuery(BRANDING_QUERY_QN, {});
|
|
65
|
-
const mjmlMode = tab === "mjml";
|
|
66
|
-
// An mjml layout brings its own look; the appearance fields only drive structured (html) layouts.
|
|
67
|
-
const appearanceFromMjml = source.effectiveFormat === "mjml" || mjmlMode;
|
|
68
|
-
const appearanceEditable = isMail && !appearanceFromMjml;
|
|
69
|
-
const appearanceDirty = appearanceEditable && !sameAppearance(appearance, storedAppearance);
|
|
70
|
-
const appearanceValid = !appearanceEditable || isHexColor(appearance.outerBackground);
|
|
71
|
-
const htmlDirty = header !== source.regions.header ||
|
|
72
|
-
signature !== source.regions.signature ||
|
|
73
|
-
footer !== source.regions.footer ||
|
|
74
|
-
appearanceDirty;
|
|
75
|
-
const dirty = mjmlMode ? mjml !== (source.mjmlSource ?? "") : htmlDirty;
|
|
76
|
-
const content = mjmlMode
|
|
77
|
-
? mjml
|
|
78
|
-
: composeLayoutHtml({ header, signature: isMail ? signature : "", footer });
|
|
79
|
-
const debouncedContent = useDebouncedValue(content, VALIDATE_DEBOUNCE_MS);
|
|
80
|
-
const validation = useQuery(TemplateResolverQueries.validate, {
|
|
81
|
-
slug,
|
|
82
|
-
kind,
|
|
83
|
-
contentFormat: mjmlMode ? "mjml" : "html",
|
|
84
|
-
content: debouncedContent,
|
|
85
|
-
locale: source.locale,
|
|
86
|
-
scope: isSystemAdmin ? "system" : "tenant",
|
|
87
|
-
});
|
|
88
|
-
const checked = validation.data;
|
|
89
|
-
const shownRow = isSystemAdmin ? source.system : (source.tenant ?? source.system);
|
|
90
|
-
const userCount = usedByCount;
|
|
91
|
-
const placeholders = checked?.systemPlaceholders ?? [];
|
|
92
|
-
const placeholderLabels = systemLabelMap(t, placeholders);
|
|
93
|
-
const requestKind = (next) => {
|
|
94
|
-
if (next === kind)
|
|
95
|
-
return;
|
|
96
|
-
if (dirty)
|
|
97
|
-
setPendingKind(next);
|
|
98
|
-
else
|
|
99
|
-
onKindChange(next);
|
|
100
|
-
};
|
|
101
|
-
const save = async (publish) => {
|
|
102
|
-
setSaveError(null);
|
|
103
|
-
const base = {
|
|
104
|
-
slug,
|
|
105
|
-
kind,
|
|
106
|
-
locale: source.locale,
|
|
107
|
-
content,
|
|
108
|
-
contentFormat: mjmlMode ? "mjml" : "html",
|
|
109
|
-
meta: appearanceEditable ? metaToSave(appearance, source.meta) : isMail ? source.meta : {},
|
|
110
|
-
};
|
|
111
|
-
const saved = isSystemAdmin
|
|
112
|
-
? await upsertSystem.mutate(base)
|
|
113
|
-
: await upsertTenant.mutate({ ...base, status: publish ? "active" : "draft" });
|
|
114
|
-
if (!saved.isSuccess) {
|
|
115
|
-
setSaveError(dispatcherErrorText(saved.error, t));
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
if (isSystemAdmin && publish) {
|
|
119
|
-
const published = await publishSystem.mutate({ id: saved.data.id });
|
|
120
|
-
if (!published.isSuccess) {
|
|
121
|
-
setSaveError(dispatcherErrorText(published.error, t));
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
await onReload();
|
|
126
|
-
};
|
|
127
|
-
const busy = upsertTenant.pending || upsertSystem.pending || publishSystem.pending;
|
|
128
|
-
const shownStatus = shownRow === null ? null : asRowStatus(shownRow.status);
|
|
129
|
-
const canSave = dirty && checked?.canSave === true && appearanceValid && !busy;
|
|
130
|
-
const canPublish = checked?.canPublish === true && appearanceValid && (dirty || shownStatus === "draft") && !busy;
|
|
131
|
-
const blockingIssue = checked === null ? undefined : [...checked.issues, ...checked.publishIssues][0];
|
|
132
|
-
const footerMessage = checked === null
|
|
133
|
-
? null
|
|
134
|
-
: !appearanceValid
|
|
135
|
-
? { tone: "bad", text: t("documentDesigner.appearance.backgroundInvalid") }
|
|
136
|
-
: blockingIssue !== undefined
|
|
137
|
-
? { tone: "bad", text: issueText(t, blockingIssue) }
|
|
138
|
-
: {
|
|
139
|
-
tone: "ok",
|
|
140
|
-
text: t(pluralKey(isMail
|
|
141
|
-
? "documentDesigner.editor.footer.okMail"
|
|
142
|
-
: "documentDesigner.editor.footer.okPdf", userCount), { count: userCount }),
|
|
143
|
-
};
|
|
144
|
-
const accentColor = branding.data?.accentColor ?? "";
|
|
145
|
-
const showAccentLine = isMail && appearanceEditable && appearance.accentLine === "branding" && isHexColor(accentColor);
|
|
146
|
-
// A layout region has no per-mail variables, so a steps block there could never show a stage and an images block no photos.
|
|
147
|
-
const blocks = (isMail ? MAIL_DOCUMENT_BLOCKS : PDF_DOCUMENT_BLOCKS).filter((block) => block !== "steps" && block !== "images");
|
|
148
|
-
// The layout's "logo" resource is the branding logo, so the editor shows it instead of a chip.
|
|
149
|
-
const logoFile = branding.data?.logoFile ?? "";
|
|
150
|
-
const resourcePreviews = logoFile === ""
|
|
151
|
-
? undefined
|
|
152
|
-
: {
|
|
153
|
-
logo: {
|
|
154
|
-
src: fileUrl(logoFile),
|
|
155
|
-
caption: isMail ? t("documentDesigner.editor.logoCaption") : "",
|
|
156
|
-
},
|
|
157
|
-
};
|
|
158
|
-
const regionEditor = (region, value, onChange) => (_jsx(DocumentContentEditor, { id: REGION_EDITOR_IDS[region], value: value, onChange: onChange, variables: placeholders, variableLabels: placeholderLabels, systemVariables: placeholders, readOnly: false, blocks: blocks, ...(resourcePreviews && { resourcePreviews }), chrome: "canvas", toolbarHost: activeRegion === region ? toolbarHost : null }));
|
|
27
|
+
const m = useLayoutEditorState(props, t);
|
|
28
|
+
const { pendingKind, setPendingKind } = m;
|
|
159
29
|
return (_jsxs(EditorFrame, { testId: "document-designer-layout-editor", children: [_jsx(EditorHeader, { title: t(kind === "mail-layout"
|
|
160
30
|
? "documentDesigner.editor.title.mail"
|
|
161
|
-
: "documentDesigner.editor.title.pdf"), stored: shownRow, actions: {
|
|
162
|
-
busy,
|
|
163
|
-
canSave,
|
|
164
|
-
canPublish,
|
|
165
|
-
onSave: () => void save(false),
|
|
166
|
-
onPublish: () => void save(true),
|
|
167
|
-
}, children: _jsx(
|
|
168
|
-
{ value: "mail-layout", label: t("documentDesigner.editor.kind.mail") },
|
|
169
|
-
{ value: "pdf-layout", label: t("documentDesigner.editor.kind.pdf") },
|
|
170
|
-
] }) }), _jsx(OriginBar, { isSystemAdmin: isSystemAdmin, source: source, kind: kind, userCount: userCount, overrides: overrides, onReset: () => void onReload(), onResetError: setSaveError }), _jsx(EditorBody, { left: _jsxs(_Fragment, { children: [_jsx(BrandingRail, { data: branding.data, unavailable: branding.error !== null || (branding.data === null && !branding.loading) }), _jsx(PlaceholderRail, { validation: checked }), _jsx(RendererRail, { kind: kind })] }), center: _jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center border-b bg-background", children: [_jsx("div", { ref: setToolbarHost, className: "min-w-0 flex-1" }), showMjmlTab && (_jsx("div", { className: "shrink-0 px-3 py-1", children: _jsx(ModeSwitch, { value: tab, onChange: setTab, ariaLabel: t("documentDesigner.editor.tabSwitch"), testId: "layout-mjml-tab", options: [
|
|
171
|
-
{ value: "draft", label: t("documentDesigner.editor.tab.draft") },
|
|
172
|
-
{ value: "mjml", label: t("documentDesigner.editor.tab.mjml") },
|
|
173
|
-
] }) }))] }), _jsx("div", { className: "min-h-0 flex-1 overflow-auto px-8 py-6", children: mjmlMode ? (_jsx(TextareaField, { id: "document-designer-mjml-source", name: "mjml", label: t("documentDesigner.editor.mjml.label"), value: mjml, onChange: setMjml, rows: 24, testId: "document-designer-mjml-source" })) : (_jsxs("div", { className: "flex flex-col gap-3", children: [source.effectiveFormat === "mjml" && (_jsx(Banner, { variant: "info", testId: "document-designer-mjml-notice", children: t(isSystemAdmin
|
|
174
|
-
? "documentDesigner.editor.mjmlNotice.platform"
|
|
175
|
-
: "documentDesigner.editor.mjmlNotice.tenant") })), _jsxs("div", { className: cn("mx-auto flex w-full flex-col gap-5 bg-card px-8 py-6 shadow-md", isMail ? PAPER_WIDTH_CLASS[appearance.width] : "min-h-[842px] max-w-[595px]", showAccentLine && "border-t-4 border-t-(--layout-accent)"), ref: (element) => element?.style.setProperty("--layout-accent", accentColor), "data-testid": "document-designer-paper", children: [_jsx(PaperRegion, { label: t("documentDesigner.editor.region.header"), testId: "document-designer-region-header", onFocus: () => setActiveRegion("header"), children: regionEditor("header", header, setHeader) }), _jsxs("div", { className: "rounded-sm border-2 border-dashed border-primary bg-primary/10 px-3 py-9 text-center font-medium text-primary", "data-testid": "document-designer-region-content", children: ["\u229E ", t("documentDesigner.editor.region.content"), _jsx("small", { className: "mt-1 block text-xs font-normal opacity-85", children: t(isMail
|
|
176
|
-
? "documentDesigner.editor.slot.mail"
|
|
177
|
-
: "documentDesigner.editor.slot.pdf") })] }), isMail && (_jsx(PaperRegion, { label: t("documentDesigner.editor.region.signature"), testId: "document-designer-region-signature", onFocus: () => setActiveRegion("signature"), children: regionEditor("signature", signature, setSignature) })), _jsx("div", { className: cn(isMail && "-mx-8 -mb-6 bg-muted px-8 py-4"), children: _jsx(PaperRegion, { label: t("documentDesigner.editor.region.footer"), testId: "document-designer-region-footer", onFocus: () => setActiveRegion("footer"), children: regionEditor("footer", footer, setFooter) }) })] })] })) })] }), right: _jsxs(_Fragment, { children: [isMail && (_jsx(AppearanceRail, { appearance: appearance, onChange: setAppearance, fromMjml: appearanceFromMjml })), isMail && !mjmlMode && _jsx(MjmlSourceRail, { mjmlSource: source.mjmlSource }), _jsx(UsedByRail, { layoutSlug: slug, kind: kind, rows: overviewRows })] }) }), _jsx(EditorFooter, { testIdPrefix: "document-designer", message: footerMessage, saveError: saveError, modifiedAt: null, dirty: dirty, busy: busy, canSave: canSave, canPublish: canPublish, onDiscard: () => {
|
|
178
|
-
setHeader(source.regions.header);
|
|
179
|
-
setSignature(source.regions.signature);
|
|
180
|
-
setFooter(source.regions.footer);
|
|
181
|
-
setMjml(source.mjmlSource ?? "");
|
|
182
|
-
setAppearance(storedAppearance);
|
|
183
|
-
setSaveError(null);
|
|
184
|
-
}, onSave: () => void save(false), onPublish: () => void save(true) }), _jsx(Dialog, { open: pendingKind !== null, onOpenChange: (open) => {
|
|
31
|
+
: "documentDesigner.editor.title.pdf"), stored: m.shownRow, actions: {
|
|
32
|
+
busy: m.busy,
|
|
33
|
+
canSave: m.canSave,
|
|
34
|
+
canPublish: m.canPublish,
|
|
35
|
+
onSave: () => void m.save(false),
|
|
36
|
+
onPublish: () => void m.save(true),
|
|
37
|
+
}, children: renderKindSwitch(t, props, m) }), _jsx(OriginBar, { isSystemAdmin: isSystemAdmin, source: source, kind: kind, userCount: m.userCount, overrides: overrides, onReset: () => void onReload(), onResetError: m.setSaveError }), _jsx(EditorBody, { left: renderLeftRail(props, m), center: renderCenter(Banner, t, props, m), right: renderRightRail(props, m) }), _jsx(EditorFooter, { testIdPrefix: "document-designer", message: m.footerMessage, saveError: m.saveError, modifiedAt: null, dirty: m.dirty, busy: m.busy, canSave: m.canSave, canPublish: m.canPublish, onDiscard: m.discard, onSave: () => void m.save(false), onPublish: () => void m.save(true) }), _jsx(Dialog, { open: pendingKind !== null, onOpenChange: (open) => {
|
|
185
38
|
if (!open)
|
|
186
39
|
setPendingKind(null);
|
|
187
40
|
}, title: t("documentDesigner.editor.dirtySwitch.title"), description: t("documentDesigner.editor.dirtySwitch.description"), confirmLabel: t("documentDesigner.editor.dirtySwitch.confirm"), variant: "danger", onConfirm: () => {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { usePrimitives } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import type { Translate } from "./layout-editor-logic.ts";
|
|
4
|
+
import type { LayoutEditorBodyProps, LayoutEditorModel } from "./use-layout-editor-state.ts";
|
|
5
|
+
type Banner = ReturnType<typeof usePrimitives>["Banner"];
|
|
6
|
+
export declare function renderCenter(Banner: Banner, t: Translate, props: LayoutEditorBodyProps, m: LayoutEditorModel): ReactNode;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { ModeSwitch, TextareaField } from "@cosmicdrift/kumiko-renderer/web";
|
|
3
|
+
import { renderPaper } from "./layout-editor-paper.js";
|
|
4
|
+
function renderToolbarRow(t, m) {
|
|
5
|
+
return (_jsxs("div", { className: "flex items-center border-b bg-background", children: [_jsx("div", { ref: m.setToolbarHost, className: "min-w-0 flex-1" }), m.showMjmlTab && (_jsx("div", { className: "shrink-0 px-3 py-1", children: _jsx(ModeSwitch, { value: m.tab, onChange: m.setTab, ariaLabel: t("documentDesigner.editor.tabSwitch"), testId: "layout-mjml-tab", options: [
|
|
6
|
+
{ value: "draft", label: t("documentDesigner.editor.tab.draft") },
|
|
7
|
+
{ value: "mjml", label: t("documentDesigner.editor.tab.mjml") },
|
|
8
|
+
] }) }))] }));
|
|
9
|
+
}
|
|
10
|
+
function renderMjmlNotice(Banner, t, props) {
|
|
11
|
+
return (_jsx(Banner, { variant: "info", testId: "document-designer-mjml-notice", children: t(props.isSystemAdmin
|
|
12
|
+
? "documentDesigner.editor.mjmlNotice.platform"
|
|
13
|
+
: "documentDesigner.editor.mjmlNotice.tenant") }));
|
|
14
|
+
}
|
|
15
|
+
function renderDraftCanvas(Banner, t, props, m) {
|
|
16
|
+
return (_jsxs("div", { className: "flex flex-col gap-3", children: [props.source.effectiveFormat === "mjml" && renderMjmlNotice(Banner, t, props), renderPaper(t, m)] }));
|
|
17
|
+
}
|
|
18
|
+
function renderMjmlSource(t, m) {
|
|
19
|
+
return (_jsx(TextareaField, { id: "document-designer-mjml-source", name: "mjml", label: t("documentDesigner.editor.mjml.label"), value: m.mjml, onChange: m.setMjml, rows: 24, testId: "document-designer-mjml-source" }));
|
|
20
|
+
}
|
|
21
|
+
export function renderCenter(Banner, t, props, m) {
|
|
22
|
+
return (_jsxs(_Fragment, { children: [renderToolbarRow(t, m), _jsx("div", { className: "min-h-0 flex-1 overflow-auto px-8 py-6", children: m.mjmlMode ? renderMjmlSource(t, m) : renderDraftCanvas(Banner, t, props, m) })] }));
|
|
23
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { type useMutation } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import type { FooterTone } from "./editor-shell.tsx";
|
|
3
|
+
import { type LayoutAppearance, type LayoutWidth } from "./layout-meta.ts";
|
|
4
|
+
import type { BrandingView, LayoutKind, LayoutSourceView, LayoutValidation, UpsertResponse, ValidationIssue } from "./layout-types.ts";
|
|
5
|
+
export type EditorTab = "draft" | "mjml";
|
|
6
|
+
export type Region = "header" | "signature" | "footer";
|
|
7
|
+
export type Translate = (key: string, params?: Readonly<Record<string, unknown>>) => string;
|
|
8
|
+
export declare const REGION_EDITOR_IDS: Readonly<Record<Region, string>>;
|
|
9
|
+
export declare const PAPER_WIDTH_CLASS: Readonly<Record<LayoutWidth, string>>;
|
|
10
|
+
export type FooterMessage = {
|
|
11
|
+
readonly tone: FooterTone;
|
|
12
|
+
readonly text: string;
|
|
13
|
+
} | null;
|
|
14
|
+
export type ResourcePreviews = Readonly<Record<string, {
|
|
15
|
+
readonly src: string;
|
|
16
|
+
readonly caption: string;
|
|
17
|
+
}>>;
|
|
18
|
+
export declare function issueText(t: Translate, issue: ValidationIssue): string;
|
|
19
|
+
export declare function sameAppearance(a: LayoutAppearance, b: LayoutAppearance): boolean;
|
|
20
|
+
export declare function initialTab(showMjmlTab: boolean, source: LayoutSourceView): EditorTab;
|
|
21
|
+
export declare function layoutDirty(input: {
|
|
22
|
+
readonly mjmlMode: boolean;
|
|
23
|
+
readonly mjml: string;
|
|
24
|
+
readonly header: string;
|
|
25
|
+
readonly signature: string;
|
|
26
|
+
readonly footer: string;
|
|
27
|
+
readonly appearance: LayoutAppearance;
|
|
28
|
+
readonly storedAppearance: LayoutAppearance;
|
|
29
|
+
readonly appearanceEditable: boolean;
|
|
30
|
+
readonly source: LayoutSourceView;
|
|
31
|
+
}): boolean;
|
|
32
|
+
export declare function layoutContent(input: {
|
|
33
|
+
readonly mjmlMode: boolean;
|
|
34
|
+
readonly mjml: string;
|
|
35
|
+
readonly header: string;
|
|
36
|
+
readonly signature: string;
|
|
37
|
+
readonly footer: string;
|
|
38
|
+
readonly isMail: boolean;
|
|
39
|
+
}): string;
|
|
40
|
+
export declare function footerMessageOf(input: {
|
|
41
|
+
readonly t: Translate;
|
|
42
|
+
readonly checked: LayoutValidation | null;
|
|
43
|
+
readonly appearanceValid: boolean;
|
|
44
|
+
readonly isMail: boolean;
|
|
45
|
+
readonly userCount: number;
|
|
46
|
+
}): FooterMessage;
|
|
47
|
+
export declare function layoutBlocks(isMail: boolean): ("align" | "button" | "divider" | "image" | "pageBreak" | "repeat" | "table")[];
|
|
48
|
+
export declare function resourcePreviewsOf(branding: BrandingView | null, isMail: boolean, t: Translate): ResourcePreviews | undefined;
|
|
49
|
+
type UpsertMutation = ReturnType<typeof useMutation<UpsertResponse>>;
|
|
50
|
+
type PublishMutation = ReturnType<typeof useMutation>;
|
|
51
|
+
export type SaveLayoutInput = {
|
|
52
|
+
readonly publish: boolean;
|
|
53
|
+
readonly kind: LayoutKind;
|
|
54
|
+
readonly slug: string;
|
|
55
|
+
readonly source: LayoutSourceView;
|
|
56
|
+
readonly content: string;
|
|
57
|
+
readonly mjmlMode: boolean;
|
|
58
|
+
readonly isMail: boolean;
|
|
59
|
+
readonly isSystemAdmin: boolean;
|
|
60
|
+
readonly appearanceEditable: boolean;
|
|
61
|
+
readonly appearance: LayoutAppearance;
|
|
62
|
+
readonly t: Translate;
|
|
63
|
+
readonly upsertTenant: UpsertMutation;
|
|
64
|
+
readonly upsertSystem: UpsertMutation;
|
|
65
|
+
readonly publishSystem: PublishMutation;
|
|
66
|
+
readonly setSaveError: (message: string | null) => void;
|
|
67
|
+
readonly onReload: () => Promise<void>;
|
|
68
|
+
};
|
|
69
|
+
export declare function saveLayout(input: SaveLayoutInput): Promise<void>;
|
|
70
|
+
export declare function accentLineShown(input: {
|
|
71
|
+
readonly isMail: boolean;
|
|
72
|
+
readonly appearanceEditable: boolean;
|
|
73
|
+
readonly appearance: LayoutAppearance;
|
|
74
|
+
readonly accentColor: string;
|
|
75
|
+
}): boolean;
|
|
76
|
+
export declare function appearanceFlags(input: {
|
|
77
|
+
readonly isMail: boolean;
|
|
78
|
+
readonly mjmlMode: boolean;
|
|
79
|
+
readonly source: LayoutSourceView;
|
|
80
|
+
readonly appearance: LayoutAppearance;
|
|
81
|
+
}): {
|
|
82
|
+
appearanceFromMjml: boolean;
|
|
83
|
+
appearanceEditable: boolean;
|
|
84
|
+
appearanceValid: boolean;
|
|
85
|
+
};
|
|
86
|
+
export declare function shownRowOf(isSystemAdmin: boolean, source: LayoutSourceView): import("./layout-types.ts").LayoutSourceRow | null;
|
|
87
|
+
export declare function layoutSaveFlags(input: {
|
|
88
|
+
readonly dirty: boolean;
|
|
89
|
+
readonly checked: LayoutValidation | null;
|
|
90
|
+
readonly appearanceValid: boolean;
|
|
91
|
+
readonly busy: boolean;
|
|
92
|
+
readonly shownRow: ReturnType<typeof shownRowOf>;
|
|
93
|
+
}): {
|
|
94
|
+
canSave: boolean;
|
|
95
|
+
canPublish: boolean;
|
|
96
|
+
};
|
|
97
|
+
export {};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { MAIL_DOCUMENT_BLOCKS, PDF_DOCUMENT_BLOCKS, } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
|
|
2
|
+
import { dispatcherErrorText } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { pluralKey } from "@cosmicdrift/kumiko-renderer/headless";
|
|
4
|
+
import { composeLayoutHtml } from "./compose-layout.js";
|
|
5
|
+
import { fileUrl } from "./file-name.js";
|
|
6
|
+
import { isHexColor, metaToSave } from "./layout-meta.js";
|
|
7
|
+
import { asRowStatus } from "./status-label.js";
|
|
8
|
+
export const REGION_EDITOR_IDS = {
|
|
9
|
+
header: "document-designer-region-header",
|
|
10
|
+
signature: "document-designer-region-signature",
|
|
11
|
+
footer: "document-designer-region-footer",
|
|
12
|
+
};
|
|
13
|
+
export const PAPER_WIDTH_CLASS = {
|
|
14
|
+
560: "max-w-[560px]",
|
|
15
|
+
600: "max-w-[600px]",
|
|
16
|
+
640: "max-w-[640px]",
|
|
17
|
+
};
|
|
18
|
+
export function issueText(t, issue) {
|
|
19
|
+
const translated = t(issue.i18nKey, issue.params);
|
|
20
|
+
return translated === issue.i18nKey ? issue.code : translated;
|
|
21
|
+
}
|
|
22
|
+
export function sameAppearance(a, b) {
|
|
23
|
+
return (a.width === b.width &&
|
|
24
|
+
a.font === b.font &&
|
|
25
|
+
a.outerBackground === b.outerBackground &&
|
|
26
|
+
a.accentLine === b.accentLine &&
|
|
27
|
+
a.buttonStyle === b.buttonStyle);
|
|
28
|
+
}
|
|
29
|
+
export function initialTab(showMjmlTab, source) {
|
|
30
|
+
return showMjmlTab && source.effectiveFormat === "mjml" ? "mjml" : "draft";
|
|
31
|
+
}
|
|
32
|
+
export function layoutDirty(input) {
|
|
33
|
+
const { source } = input;
|
|
34
|
+
const appearanceDirty = input.appearanceEditable && !sameAppearance(input.appearance, input.storedAppearance);
|
|
35
|
+
const htmlDirty = input.header !== source.regions.header ||
|
|
36
|
+
input.signature !== source.regions.signature ||
|
|
37
|
+
input.footer !== source.regions.footer ||
|
|
38
|
+
appearanceDirty;
|
|
39
|
+
return input.mjmlMode ? input.mjml !== (source.mjmlSource ?? "") : htmlDirty;
|
|
40
|
+
}
|
|
41
|
+
export function layoutContent(input) {
|
|
42
|
+
return input.mjmlMode
|
|
43
|
+
? input.mjml
|
|
44
|
+
: composeLayoutHtml({
|
|
45
|
+
header: input.header,
|
|
46
|
+
signature: input.isMail ? input.signature : "",
|
|
47
|
+
footer: input.footer,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
export function footerMessageOf(input) {
|
|
51
|
+
const { t, checked, userCount } = input;
|
|
52
|
+
if (checked === null)
|
|
53
|
+
return null; // skip: nothing validated yet, no footer message
|
|
54
|
+
if (!input.appearanceValid) {
|
|
55
|
+
return { tone: "bad", text: t("documentDesigner.appearance.backgroundInvalid") };
|
|
56
|
+
}
|
|
57
|
+
const blockingIssue = [...checked.issues, ...checked.publishIssues][0];
|
|
58
|
+
if (blockingIssue !== undefined)
|
|
59
|
+
return { tone: "bad", text: issueText(t, blockingIssue) };
|
|
60
|
+
return {
|
|
61
|
+
tone: "ok",
|
|
62
|
+
text: t(pluralKey(input.isMail
|
|
63
|
+
? "documentDesigner.editor.footer.okMail"
|
|
64
|
+
: "documentDesigner.editor.footer.okPdf", userCount), { count: userCount }),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// A layout region has no per-mail variables, so a steps block there could never show a stage and an images block no photos.
|
|
68
|
+
export function layoutBlocks(isMail) {
|
|
69
|
+
return (isMail ? MAIL_DOCUMENT_BLOCKS : PDF_DOCUMENT_BLOCKS).filter((block) => block !== "steps" && block !== "images");
|
|
70
|
+
}
|
|
71
|
+
// The layout's "logo" resource is the branding logo, so the editor shows it instead of a chip.
|
|
72
|
+
export function resourcePreviewsOf(branding, isMail, t) {
|
|
73
|
+
const logoFile = branding?.logoFile ?? "";
|
|
74
|
+
if (logoFile === "")
|
|
75
|
+
return undefined; // skip: no branding logo to preview
|
|
76
|
+
return {
|
|
77
|
+
logo: {
|
|
78
|
+
src: fileUrl(logoFile),
|
|
79
|
+
caption: isMail ? t("documentDesigner.editor.logoCaption") : "",
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function metaOf(input) {
|
|
84
|
+
if (input.appearanceEditable)
|
|
85
|
+
return metaToSave(input.appearance, input.source.meta);
|
|
86
|
+
return input.isMail ? input.source.meta : {};
|
|
87
|
+
}
|
|
88
|
+
export async function saveLayout(input) {
|
|
89
|
+
const { t, setSaveError } = input;
|
|
90
|
+
setSaveError(null);
|
|
91
|
+
const base = {
|
|
92
|
+
slug: input.slug,
|
|
93
|
+
kind: input.kind,
|
|
94
|
+
locale: input.source.locale,
|
|
95
|
+
content: input.content,
|
|
96
|
+
contentFormat: input.mjmlMode ? "mjml" : "html",
|
|
97
|
+
meta: metaOf(input),
|
|
98
|
+
};
|
|
99
|
+
const saved = input.isSystemAdmin
|
|
100
|
+
? await input.upsertSystem.mutate(base)
|
|
101
|
+
: await input.upsertTenant.mutate({ ...base, status: input.publish ? "active" : "draft" });
|
|
102
|
+
if (!saved.isSuccess) {
|
|
103
|
+
setSaveError(dispatcherErrorText(saved.error, t));
|
|
104
|
+
// skip: the failure is shown through saveError
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (input.isSystemAdmin && input.publish) {
|
|
108
|
+
const published = await input.publishSystem.mutate({ id: saved.data.id });
|
|
109
|
+
if (!published.isSuccess) {
|
|
110
|
+
setSaveError(dispatcherErrorText(published.error, t));
|
|
111
|
+
// skip: the failure is shown through saveError
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
await input.onReload();
|
|
116
|
+
}
|
|
117
|
+
export function accentLineShown(input) {
|
|
118
|
+
return (input.isMail &&
|
|
119
|
+
input.appearanceEditable &&
|
|
120
|
+
input.appearance.accentLine === "branding" &&
|
|
121
|
+
isHexColor(input.accentColor));
|
|
122
|
+
}
|
|
123
|
+
export function appearanceFlags(input) {
|
|
124
|
+
// An mjml layout brings its own look; the appearance fields only drive structured (html) layouts.
|
|
125
|
+
const appearanceFromMjml = input.source.effectiveFormat === "mjml" || input.mjmlMode;
|
|
126
|
+
const appearanceEditable = input.isMail && !appearanceFromMjml;
|
|
127
|
+
const appearanceValid = !appearanceEditable || isHexColor(input.appearance.outerBackground);
|
|
128
|
+
return { appearanceFromMjml, appearanceEditable, appearanceValid };
|
|
129
|
+
}
|
|
130
|
+
export function shownRowOf(isSystemAdmin, source) {
|
|
131
|
+
return isSystemAdmin ? source.system : (source.tenant ?? source.system);
|
|
132
|
+
}
|
|
133
|
+
export function layoutSaveFlags(input) {
|
|
134
|
+
const { checked, appearanceValid, busy } = input;
|
|
135
|
+
const shownStatus = input.shownRow === null ? null : asRowStatus(input.shownRow.status);
|
|
136
|
+
return {
|
|
137
|
+
canSave: input.dirty && checked?.canSave === true && appearanceValid && !busy,
|
|
138
|
+
canPublish: checked?.canPublish === true &&
|
|
139
|
+
appearanceValid &&
|
|
140
|
+
(input.dirty || shownStatus === "draft") &&
|
|
141
|
+
!busy,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { DocumentContentEditor } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
|
|
3
|
+
import { cn } from "@cosmicdrift/kumiko-renderer/web";
|
|
4
|
+
import { PAPER_WIDTH_CLASS, REGION_EDITOR_IDS } from "./layout-editor-logic.js";
|
|
5
|
+
// A dashed, labelled area of the paper; the focused one owns the shared toolbar.
|
|
6
|
+
function PaperRegion({ label, testId, children, onFocus, }) {
|
|
7
|
+
return (_jsxs("div", { className: "relative rounded-sm border border-dashed border-input p-3", "data-testid": testId, onFocusCapture: onFocus, children: [_jsx("span", { className: "absolute -top-2.5 left-2.5 bg-card px-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground", children: label }), children] }));
|
|
8
|
+
}
|
|
9
|
+
function renderRegionEditor(m, region, value, onChange) {
|
|
10
|
+
const { resourcePreviews } = m;
|
|
11
|
+
return (_jsx(DocumentContentEditor, { id: REGION_EDITOR_IDS[region], value: value, onChange: onChange, variables: m.placeholders, variableLabels: m.placeholderLabels, systemVariables: m.placeholders, readOnly: false, blocks: m.blocks, ...(resourcePreviews && { resourcePreviews }), chrome: "canvas", toolbarHost: m.activeRegion === region ? m.toolbarHost : null }));
|
|
12
|
+
}
|
|
13
|
+
function renderContentSlot(t, isMail) {
|
|
14
|
+
return (_jsxs("div", { className: "rounded-sm border-2 border-dashed border-primary bg-primary/10 px-3 py-9 text-center font-medium text-primary", "data-testid": "document-designer-region-content", children: ["\u229E ", t("documentDesigner.editor.region.content"), _jsx("small", { className: "mt-1 block text-xs font-normal opacity-85", children: t(isMail ? "documentDesigner.editor.slot.mail" : "documentDesigner.editor.slot.pdf") })] }));
|
|
15
|
+
}
|
|
16
|
+
function renderSignatureRegion(t, m) {
|
|
17
|
+
return (_jsx(PaperRegion, { label: t("documentDesigner.editor.region.signature"), testId: "document-designer-region-signature", onFocus: () => m.setActiveRegion("signature"), children: renderRegionEditor(m, "signature", m.signature, m.setSignature) }));
|
|
18
|
+
}
|
|
19
|
+
function renderFooterRegion(t, m) {
|
|
20
|
+
return (_jsx("div", { className: cn(m.isMail && "-mx-8 -mb-6 bg-muted px-8 py-4"), children: _jsx(PaperRegion, { label: t("documentDesigner.editor.region.footer"), testId: "document-designer-region-footer", onFocus: () => m.setActiveRegion("footer"), children: renderRegionEditor(m, "footer", m.footer, m.setFooter) }) }));
|
|
21
|
+
}
|
|
22
|
+
export function renderPaper(t, m) {
|
|
23
|
+
return (_jsxs("div", { className: cn("mx-auto flex w-full flex-col gap-5 bg-card px-8 py-6 shadow-md", m.isMail ? PAPER_WIDTH_CLASS[m.appearance.width] : "min-h-[842px] max-w-[595px]", m.showAccentLine && "border-t-4 border-t-(--layout-accent)"), ref: (element) => element?.style.setProperty("--layout-accent", m.accentColor), "data-testid": "document-designer-paper", children: [_jsx(PaperRegion, { label: t("documentDesigner.editor.region.header"), testId: "document-designer-region-header", onFocus: () => m.setActiveRegion("header"), children: renderRegionEditor(m, "header", m.header, m.setHeader) }), renderContentSlot(t, m.isMail), m.isMail && renderSignatureRegion(t, m), renderFooterRegion(t, m)] }));
|
|
24
|
+
}
|
|
@@ -1,17 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type {
|
|
3
|
-
export type MailEditorBodyProps
|
|
4
|
-
|
|
5
|
-
readonly locale: string;
|
|
6
|
-
readonly locales: readonly string[];
|
|
7
|
-
readonly title: string;
|
|
8
|
-
readonly isSystemAdmin: boolean;
|
|
9
|
-
readonly row: TemplateRowView | null;
|
|
10
|
-
readonly systemVersion: number | null;
|
|
11
|
-
readonly fallbackLocale: string | null;
|
|
12
|
-
readonly onLocaleChange: (locale: string) => void;
|
|
13
|
-
readonly onReload: () => Promise<void>;
|
|
14
|
-
readonly onRefreshRow: () => Promise<void>;
|
|
15
|
-
};
|
|
16
|
-
export declare function placeholderNames(validation: MailValidation | null): string[];
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { MailEditorBodyProps } from "./mail-editor-logic.ts";
|
|
3
|
+
export type { MailEditorBodyProps } from "./mail-editor-logic.ts";
|
|
4
|
+
export { placeholderNames } from "./mail-editor-logic.ts";
|
|
17
5
|
export declare function MailEditorBody(props: MailEditorBodyProps): ReactNode;
|