@bygghammar/cms-platform 0.4.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -67,7 +67,16 @@
67
67
  "./i18n/json": "./src/i18n/json.ts",
68
68
  "./i18n/resolve-locale": "./src/i18n/resolve-locale.ts",
69
69
  "./i18n/translation-meta": "./src/i18n/translation-meta.ts",
70
- "./platform": "./src/platform/index.ts"
70
+ "./platform": "./src/platform/index.ts",
71
+ "./admin/format-log": "./src/admin/format-log.ts",
72
+ "./backoffice/forms/ConditionBuilder": "./src/backoffice/forms/ConditionBuilder.tsx",
73
+ "./backoffice/forms/RuleSimulator": "./src/backoffice/forms/RuleSimulator.tsx",
74
+ "./backoffice/forms/SmartTags": "./src/backoffice/forms/SmartTags.tsx",
75
+ "./backoffice/forms/ActivityLog": "./src/backoffice/forms/ActivityLog.tsx",
76
+ "./backoffice/ProgressBar": "./src/backoffice/ProgressBar.tsx",
77
+ "./backoffice/PreviewLightbox": "./src/backoffice/PreviewLightbox.tsx",
78
+ "./backoffice/RichTextEditor": "./src/backoffice/RichTextEditor.tsx",
79
+ "./backoffice/media/ConfirmDialog": "./src/backoffice/media/ConfirmDialog.tsx"
71
80
  },
72
81
  "dependencies": {
73
82
  "fflate": "^0.8.3"
@@ -80,7 +89,18 @@
80
89
  "sonner": ">=2",
81
90
  "@supabase/supabase-js": ">=2",
82
91
  "@tanstack/react-start": ">=1",
83
- "@tanstack/react-router": ">=1"
92
+ "@tanstack/react-router": ">=1",
93
+ "@tiptap/react": ">=3",
94
+ "@tiptap/starter-kit": ">=3",
95
+ "@tiptap/pm": ">=3",
96
+ "@tiptap/extensions": ">=3",
97
+ "@tiptap/extension-image": ">=3",
98
+ "@tiptap/extension-text-align": ">=3",
99
+ "@tiptap/extension-highlight": ">=3",
100
+ "@tiptap/extension-subscript": ">=3",
101
+ "@tiptap/extension-superscript": ">=3",
102
+ "@tiptap/extension-text-style": ">=3",
103
+ "@tiptap/extension-table": ">=3"
84
104
  },
85
105
  "peerDependenciesMeta": {
86
106
  "@supabase/supabase-js": {
@@ -91,6 +111,39 @@
91
111
  },
92
112
  "@tanstack/react-router": {
93
113
  "optional": true
114
+ },
115
+ "@tiptap/react": {
116
+ "optional": true
117
+ },
118
+ "@tiptap/starter-kit": {
119
+ "optional": true
120
+ },
121
+ "@tiptap/pm": {
122
+ "optional": true
123
+ },
124
+ "@tiptap/extensions": {
125
+ "optional": true
126
+ },
127
+ "@tiptap/extension-image": {
128
+ "optional": true
129
+ },
130
+ "@tiptap/extension-text-align": {
131
+ "optional": true
132
+ },
133
+ "@tiptap/extension-highlight": {
134
+ "optional": true
135
+ },
136
+ "@tiptap/extension-subscript": {
137
+ "optional": true
138
+ },
139
+ "@tiptap/extension-superscript": {
140
+ "optional": true
141
+ },
142
+ "@tiptap/extension-text-style": {
143
+ "optional": true
144
+ },
145
+ "@tiptap/extension-table": {
146
+ "optional": true
94
147
  }
95
148
  }
96
149
  }
@@ -0,0 +1,217 @@
1
+ // Turn machine action codes and JSON meta into human-readable strings for the
2
+ // backoffice Audit and Email log views.
3
+
4
+ const ACTION_LABELS: Record<string, string> = {
5
+ "user.create": "Created user",
6
+ "user.delete": "Deleted user",
7
+ "user.password_reset": "Reset password",
8
+ "user.invite": "Invited user",
9
+ "role.grant": "Granted role",
10
+ "role.revoke": "Revoked role",
11
+ "role.update": "Updated role",
12
+ "email.settings.update": "Updated email settings",
13
+ "email.settings.test": "Sent test email",
14
+ "content.section.save": "Saved section",
15
+ "content.section.delete": "Deleted section",
16
+ "page.create": "Created page",
17
+ "page.update": "Updated page",
18
+ "page.delete": "Deleted page",
19
+ "page.publish": "Published page",
20
+ "page.unpublish": "Unpublished page",
21
+ "page.version.create": "Saved page version",
22
+ "page.version.restore": "Restored page version",
23
+ "template.create": "Created block template",
24
+ "template.update": "Updated block template",
25
+ "template.delete": "Deleted block template",
26
+ "global.section.save": "Saved global block",
27
+ "global.section.delete": "Deleted global block",
28
+ "domain.create": "Added locale domain",
29
+ "domain.update": "Updated locale domain",
30
+ "domain.delete": "Removed locale domain",
31
+ "seo.update": "Updated SEO",
32
+ "media.upload": "Uploaded media",
33
+ "media.delete": "Deleted media",
34
+ "media.import": "Imported external media",
35
+ "translate.run": "Ran auto-translation",
36
+ "form.create": "Created form",
37
+ "form.update": "Updated form",
38
+ "form.duplicate": "Duplicated form",
39
+ "form.soft_delete": "Moved form to bin",
40
+ "form.restore": "Restored form",
41
+ "form.purge": "Permanently deleted form",
42
+ "form.defaults.update": "Updated shared form defaults",
43
+ "auth.login": "Signed in",
44
+ "auth.logout": "Signed out",
45
+ };
46
+
47
+ const ENTITY_LABELS: Record<string, string> = {
48
+ user: "User",
49
+ user_role: "Role",
50
+ content_section: "Section",
51
+ page: "Page",
52
+ page_version: "Page version",
53
+ page_publication: "Publication",
54
+ block_template: "Template",
55
+ global_section: "Global block",
56
+ locale_domain: "Domain",
57
+ page_seo: "SEO",
58
+ email_settings: "Email settings",
59
+ media: "Media",
60
+ redirect: "Redirect",
61
+ forms: "Form",
62
+ };
63
+
64
+ export function humanizeAction(action: string | null | undefined): string {
65
+ if (!action) return "—";
66
+ if (ACTION_LABELS[action]) return ACTION_LABELS[action];
67
+ // Fallback: turn "some.snake_case.action" into "Some snake case action".
68
+ return action
69
+ .replace(/[._-]+/g, " ")
70
+ .replace(/\s+/g, " ")
71
+ .trim()
72
+ .replace(/^./, (c) => c.toUpperCase());
73
+ }
74
+
75
+ export function humanizeEntity(entity: string | null | undefined): string {
76
+ if (!entity) return "—";
77
+ return (
78
+ ENTITY_LABELS[entity] ?? entity.replace(/[._-]+/g, " ").replace(/^./, (c) => c.toUpperCase())
79
+ );
80
+ }
81
+
82
+ function shortId(id: string): string {
83
+ if (id.length <= 12) return id;
84
+ // UUID-style: keep first + last block for context
85
+ if (/^[0-9a-f]{8}-/i.test(id)) return id.slice(0, 8) + "…";
86
+ return id.slice(0, 10) + "…";
87
+ }
88
+
89
+ export function formatEntityRef(
90
+ entity: string | null | undefined,
91
+ entityId: string | null | undefined,
92
+ ): string {
93
+ const label = humanizeEntity(entity);
94
+ if (!entityId) return label;
95
+ return `${label} · ${shortId(entityId)}`;
96
+ }
97
+
98
+ /**
99
+ * Convert an arbitrary `meta` payload into a short prose summary suitable for
100
+ * a log row. Falls back to compact key: value pairs, and finally to nothing.
101
+ */
102
+ export function summarizeMeta(meta: unknown): string {
103
+ if (!meta || typeof meta !== "object") return "";
104
+ const m = meta as Record<string, unknown>;
105
+ if (!Object.keys(m).length) return "";
106
+
107
+ const parts: string[] = [];
108
+ const push = (label: string, value: unknown) => {
109
+ if (value == null || value === "") return;
110
+ if (typeof value === "object") {
111
+ try {
112
+ parts.push(`${label}: ${JSON.stringify(value)}`);
113
+ } catch {
114
+ /* noop */
115
+ }
116
+ return;
117
+ }
118
+ parts.push(`${label}: ${String(value)}`);
119
+ };
120
+
121
+ // Common well-known fields, in a nice order
122
+ const known = [
123
+ ["summary", "Summary"],
124
+ ["email", "Email"],
125
+ ["role", "Role"],
126
+ ["name", "Name"],
127
+ ["key", "Key"],
128
+ ["slug", "Slug"],
129
+ ["locale", "Locale"],
130
+ ["from", "From"],
131
+ ["to", "To"],
132
+ ["reason", "Reason"],
133
+ ["status", "Status"],
134
+ ["version", "Version"],
135
+ ["count", "Count"],
136
+ ["path", "Path"],
137
+ ["domain", "Domain"],
138
+ ["title", "Title"],
139
+ ] as const;
140
+ const used = new Set<string>();
141
+ for (const [k, label] of known) {
142
+ if (k in m) {
143
+ push(label, m[k]);
144
+ used.add(k);
145
+ }
146
+ }
147
+ // Any remaining keys, up to 4 total extra fields
148
+ const extras = Object.keys(m)
149
+ .filter((k) => !used.has(k))
150
+ .slice(0, 4);
151
+ for (const k of extras) {
152
+ push(
153
+ k.replace(/[._-]+/g, " ").replace(/^./, (c) => c.toUpperCase()),
154
+ m[k],
155
+ );
156
+ }
157
+ return parts.join(" · ");
158
+ }
159
+
160
+ export function formatRelativeTime(iso: string): string {
161
+ const then = new Date(iso).getTime();
162
+ if (Number.isNaN(then)) return "—";
163
+ const diff = Date.now() - then;
164
+ const s = Math.round(diff / 1000);
165
+ if (s < 45) return "just now";
166
+ const m = Math.round(s / 60);
167
+ if (m < 60) return `${m} min ago`;
168
+ const h = Math.round(m / 60);
169
+ if (h < 24) return `${h} h ago`;
170
+ const d = Math.round(h / 24);
171
+ if (d < 30) return `${d} d ago`;
172
+ const mo = Math.round(d / 30);
173
+ if (mo < 12) return `${mo} mo ago`;
174
+ const y = Math.round(mo / 12);
175
+ return `${y} y ago`;
176
+ }
177
+
178
+ export function formatAbsoluteTime(iso: string): string {
179
+ const d = new Date(iso);
180
+ if (Number.isNaN(d.getTime())) return "—";
181
+ return d.toLocaleString(undefined, {
182
+ year: "numeric",
183
+ month: "short",
184
+ day: "2-digit",
185
+ hour: "2-digit",
186
+ minute: "2-digit",
187
+ });
188
+ }
189
+
190
+ export function emailStatusTone(status: string | null | undefined): {
191
+ label: string;
192
+ className: string;
193
+ } {
194
+ const s = (status ?? "").toLowerCase();
195
+ const map: Record<string, { label: string; className: string }> = {
196
+ sent: { label: "Sent", className: "border-green-600 text-green-700 bg-green-50" },
197
+ delivered: { label: "Delivered", className: "border-green-600 text-green-700 bg-green-50" },
198
+ queued: { label: "Queued", className: "border-ink/30 text-ink/70 bg-ink/5" },
199
+ pending: { label: "Pending", className: "border-ink/30 text-ink/70 bg-ink/5" },
200
+ failed: { label: "Failed", className: "border-red-600 text-red-700 bg-red-50" },
201
+ rejected: { label: "Rejected", className: "border-red-600 text-red-700 bg-red-50" },
202
+ bounced: { label: "Bounced", className: "border-amber-600 text-amber-700 bg-amber-50" },
203
+ complained: { label: "Complained", className: "border-amber-600 text-amber-700 bg-amber-50" },
204
+ suppressed: { label: "Suppressed", className: "border-amber-600 text-amber-700 bg-amber-50" },
205
+ unsubscribed: { label: "Unsubscribed", className: "border-ink/30 text-ink/70 bg-ink/5" },
206
+ rate_limited: {
207
+ label: "Rate limited",
208
+ className: "border-amber-600 text-amber-700 bg-amber-50",
209
+ },
210
+ };
211
+ return (
212
+ map[s] ?? {
213
+ label: status ?? "Unknown",
214
+ className: "border-ink/30 text-ink/60 bg-white",
215
+ }
216
+ );
217
+ }
@@ -0,0 +1,66 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Lightbox } from "../ui/Lightbox";
3
+
4
+ const DESIGN_WIDTH = 1280;
5
+
6
+ /**
7
+ * Backoffice preview lightbox: renders a route inside a scaled iframe.
8
+ * Closing follows the shared site behaviour — Escape, backdrop click, or a
9
+ * click on the preview itself (the frame is look-only, covered by a
10
+ * transparent click catcher because iframe clicks never bubble out).
11
+ */
12
+ export function PreviewLightbox({
13
+ open,
14
+ onClose,
15
+ src,
16
+ title,
17
+ }: {
18
+ open: boolean;
19
+ onClose: () => void;
20
+ src: string;
21
+ title: string;
22
+ }) {
23
+ const [scale, setScale] = useState(1);
24
+ const [frameHeight, setFrameHeight] = useState(800);
25
+
26
+ useEffect(() => {
27
+ if (!open) return;
28
+ const measure = () => {
29
+ const availW = Math.max(320, window.innerWidth - 96);
30
+ const availH = Math.max(320, window.innerHeight - 120);
31
+ setScale(Math.min(1, availW / DESIGN_WIDTH));
32
+ setFrameHeight(Math.round(availH / Math.min(1, availW / DESIGN_WIDTH)));
33
+ };
34
+ measure();
35
+ window.addEventListener("resize", measure);
36
+ return () => window.removeEventListener("resize", measure);
37
+ }, [open]);
38
+
39
+ if (!open) return null;
40
+
41
+ return (
42
+ <Lightbox open={open} onClose={onClose} label={title} closeOnContentClick>
43
+ <div
44
+ className="relative bg-white shadow-2xl overflow-hidden"
45
+ style={{
46
+ width: DESIGN_WIDTH * scale,
47
+ height: frameHeight * scale,
48
+ }}
49
+ >
50
+ <iframe
51
+ src={src}
52
+ title={title}
53
+ className="absolute left-0 top-0 border-0"
54
+ style={{
55
+ width: DESIGN_WIDTH,
56
+ height: frameHeight,
57
+ transform: `scale(${scale})`,
58
+ transformOrigin: "top left",
59
+ }}
60
+ />
61
+ {/* Click catcher: iframe clicks can't bubble to the overlay. */}
62
+ <div className="absolute inset-0 cursor-pointer" onClick={onClose} />
63
+ </div>
64
+ </Lightbox>
65
+ );
66
+ }
@@ -0,0 +1,36 @@
1
+ type Props = {
2
+ /** 0–100, or null for an indeterminate bar */
3
+ value: number | null;
4
+ label?: string;
5
+ detail?: string;
6
+ };
7
+
8
+ /** Slim determinate/indeterminate progress bar used by the backoffice long-running tools. */
9
+ export function ProgressBar({ value, label, detail }: Props) {
10
+ const pct = value === null ? null : Math.max(0, Math.min(100, value));
11
+ return (
12
+ <div className="w-full">
13
+ {label || detail ? (
14
+ <div className="mb-2 flex items-baseline justify-between gap-4">
15
+ <span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
16
+ <span className="text-[0.65rem] tabular-nums text-ink/45">
17
+ {detail}
18
+ {pct !== null ? `${detail ? " · " : ""}${Math.round(pct)}%` : ""}
19
+ </span>
20
+ </div>
21
+ ) : null}
22
+ <div className="h-1.5 w-full overflow-hidden bg-ink/10">
23
+ {pct === null ? (
24
+ <div className="h-full w-1/3 animate-[progress-slide_1.2s_ease-in-out_infinite] bg-sea" />
25
+ ) : (
26
+ <div
27
+ className="h-full bg-sea transition-[width] duration-300 ease-out"
28
+ style={{ width: `${pct}%` }}
29
+ />
30
+ )}
31
+ </div>
32
+ </div>
33
+ );
34
+ }
35
+
36
+ export default ProgressBar;