@bygghammar/cms-platform 0.3.1 → 0.5.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.
@@ -0,0 +1,163 @@
1
+ import { useMemo, useState } from "react";
2
+ import { Eye, EyeOff, RotateCcw } from "lucide-react";
3
+ import { tripFieldOptions, type TripSource, type FormFieldDef } from "../../forms/field-sets";
4
+ import {
5
+ fieldLabel,
6
+ hasRule,
7
+ isFieldRequired,
8
+ isFieldVisible,
9
+ normalizeCheckbox,
10
+ validateField,
11
+ } from "../../forms/conditions";
12
+
13
+ /**
14
+ * Rule simulation: type example answers and immediately see which fields
15
+ * show, which become required and which validation messages a visitor
16
+ * would get — all before publishing.
17
+ */
18
+ export function RuleSimulator({
19
+ fields,
20
+ locale = "sv",
21
+ trips,
22
+ }: {
23
+ fields: FormFieldDef[];
24
+ locale?: string;
25
+ /** Trip/course rows used to expand trip fields into selectable options. */
26
+ trips?: TripSource[];
27
+ }) {
28
+ const [values, setValues] = useState<Record<string, string>>({});
29
+ const tripItems = trips;
30
+ const resolved = useMemo(() => {
31
+ const trips = (tripItems ?? []) as TripSource[];
32
+ return fields.map((f) =>
33
+ f.type === "trip" || f.type === "course"
34
+ ? {
35
+ ...f,
36
+ type: "select" as const,
37
+ options: tripFieldOptions(trips, f.tripYear ?? f.courseYear),
38
+ }
39
+ : f,
40
+ );
41
+ }, [fields, tripItems]);
42
+ const enabled = useMemo(() => resolved.filter((f) => f.enabled), [resolved]);
43
+
44
+ const rows = enabled.map((f) => {
45
+ const visible = isFieldVisible(f, values);
46
+ return {
47
+ field: f,
48
+ visible,
49
+ required: isFieldRequired(f, values),
50
+ error: visible ? validateField(f, values, locale) : null,
51
+ conditional: hasRule(f.showWhen) || hasRule(f.requireWhen),
52
+ };
53
+ });
54
+
55
+ const errors = rows.filter((r) => r.error).length;
56
+ const hidden = rows.filter((r) => !r.visible).length;
57
+
58
+ const set = (key: string, v: string) => setValues((p) => ({ ...p, [key]: v }));
59
+
60
+ return (
61
+ <div className="space-y-4">
62
+ <div className="flex flex-wrap items-center justify-between gap-3">
63
+ <div>
64
+ <h3 className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">Rule simulation</h3>
65
+ <p className="mt-1 text-xs text-ink/45">
66
+ Example answers only — nothing is saved or sent.
67
+ </p>
68
+ </div>
69
+ <button
70
+ type="button"
71
+ onClick={() => setValues({})}
72
+ className="inline-flex items-center gap-2 border border-ink/15 px-3 py-1.5 text-[0.65rem] uppercase tracking-[0.2em] hover:border-sea"
73
+ >
74
+ <RotateCcw className="size-3.5" />
75
+ Reset
76
+ </button>
77
+ </div>
78
+
79
+ <div className="flex flex-wrap gap-3 text-[0.65rem] uppercase tracking-[0.18em] text-ink/55">
80
+ <span>{rows.length - hidden} visible</span>
81
+ <span>{hidden} hidden</span>
82
+ <span>{rows.filter((r) => r.required).length} required</span>
83
+ <span className={errors ? "text-red-500" : ""}>{errors} error(s)</span>
84
+ </div>
85
+
86
+ {enabled.length === 0 ? (
87
+ <p className="text-xs text-ink/45">No active fields to simulate.</p>
88
+ ) : null}
89
+
90
+ <div className="space-y-2">
91
+ {rows.map(({ field: f, visible, required, error, conditional }) => (
92
+ <div
93
+ key={f.key}
94
+ className={`grid gap-2 border p-3 sm:grid-cols-[1fr_1fr] ${
95
+ visible ? "border-ink/10" : "border-dashed border-ink/15 bg-ink/[0.02]"
96
+ }`}
97
+ >
98
+ <div className="min-w-0">
99
+ <div className="flex items-center gap-2 text-xs text-ink">
100
+ {visible ? (
101
+ <Eye className="size-3.5 text-sea" />
102
+ ) : (
103
+ <EyeOff className="size-3.5 text-ink/35" />
104
+ )}
105
+ <span className="truncate">{fieldLabel(f, locale)}</span>
106
+ {required ? (
107
+ <span className="text-[0.6rem] uppercase tracking-[0.16em] text-sea">
108
+ required
109
+ </span>
110
+ ) : null}
111
+ {conditional ? (
112
+ <span className="text-[0.6rem] uppercase tracking-[0.16em] text-ink/35">
113
+ conditional
114
+ </span>
115
+ ) : null}
116
+ </div>
117
+ <div className="mt-0.5 text-[0.6rem] uppercase tracking-[0.16em] text-ink/35">
118
+ {f.key} · {f.type}
119
+ </div>
120
+ {error ? <p className="mt-1 text-xs text-red-500">{error}</p> : null}
121
+ </div>
122
+
123
+ <div>
124
+ {f.type === "checkbox" ? (
125
+ <label className="flex items-center gap-2 text-xs text-ink/70">
126
+ <input
127
+ type="checkbox"
128
+ checked={normalizeCheckbox(values[f.key] ?? "") === "true"}
129
+ onChange={(e) => set(f.key, e.target.checked ? "true" : "false")}
130
+ className="size-4 accent-sea"
131
+ />
132
+ Checked
133
+ </label>
134
+ ) : f.type === "select" ? (
135
+ <select
136
+ value={values[f.key] ?? ""}
137
+ onChange={(e) => set(f.key, e.target.value)}
138
+ className="w-full border border-ink/15 px-2 py-1 text-xs"
139
+ >
140
+ <option value="">—</option>
141
+ {(f.options ?? []).map((o) => (
142
+ <option key={o.value} value={o.value}>
143
+ {o.label?.sv || o.label?.en || o.value}
144
+ </option>
145
+ ))}
146
+ </select>
147
+ ) : (
148
+ <input
149
+ type={f.type === "textarea" ? "text" : f.type}
150
+ value={values[f.key] ?? ""}
151
+ onChange={(e) => set(f.key, e.target.value)}
152
+ placeholder="Example answer"
153
+ className="w-full border border-ink/15 px-2 py-1 text-xs"
154
+ disabled={!visible}
155
+ />
156
+ )}
157
+ </div>
158
+ </div>
159
+ ))}
160
+ </div>
161
+ </div>
162
+ );
163
+ }
@@ -0,0 +1,181 @@
1
+ import { useMemo, useRef, useState } from "react";
2
+ import { Tag } from "lucide-react";
3
+ import { LocalizedField, TextField } from "../inspectors/fields";
4
+ import { fieldLabel } from "../../forms/conditions";
5
+ import type { FormFieldDef } from "../../forms/field-sets";
6
+ import type { LocalizedString } from "../../i18n";
7
+ import { SUPPORTED_LOCALES, LOCALE_LABELS, type Locale } from "../../i18n";
8
+
9
+ export type SmartTag = { tag: string; label: string };
10
+
11
+ /** System tags available in every notification and autoreply template. */
12
+ const SYSTEM_TAGS: SmartTag[] = [
13
+ { tag: "all_fields", label: "All submitted answers (table)" },
14
+ { tag: "form_name", label: "Form name" },
15
+ { tag: "form_key", label: "Form key" },
16
+ { tag: "submission_id", label: "Submission ID" },
17
+ { tag: "date", label: "Date and time" },
18
+ { tag: "locale", label: "Language" },
19
+ { tag: "page", label: "Page path" },
20
+ { tag: "page_url", label: "Page URL" },
21
+ { tag: "admin_url", label: "Link to the submission in the backoffice" },
22
+ { tag: "ip", label: "Visitor IP address" },
23
+ { tag: "user_agent", label: "Browser / user agent" },
24
+ { tag: "referrer", label: "Referring URL" },
25
+ ];
26
+
27
+ /** Field answer tags for the given form, followed by the system tags. */
28
+ export function smartTagGroups(fields: FormFieldDef[]): { title: string; tags: SmartTag[] }[] {
29
+ const answers = fields
30
+ .filter((f) => f.enabled)
31
+ .map((f) => ({ tag: f.key, label: fieldLabel(f, "sv") || f.key }));
32
+ return [
33
+ ...(answers.length ? [{ title: "Field answers", tags: answers }] : []),
34
+ { title: "Submission info", tags: SYSTEM_TAGS },
35
+ ];
36
+ }
37
+
38
+ /** Dropdown that inserts a {{smart_tag}} into the field it belongs to. */
39
+ export function SmartTags({
40
+ fields,
41
+ onInsert,
42
+ right,
43
+ }: {
44
+ fields: FormFieldDef[];
45
+ onInsert: (token: string) => void;
46
+ right?: React.ReactNode;
47
+ }) {
48
+ const [open, setOpen] = useState(false);
49
+ const groups = useMemo(() => smartTagGroups(fields), [fields]);
50
+ const wrap = useRef<HTMLDivElement>(null);
51
+
52
+ return (
53
+ <div ref={wrap} className="relative flex items-center gap-2">
54
+ {right}
55
+ <button
56
+ type="button"
57
+ onClick={() => setOpen((o) => !o)}
58
+ className="inline-flex items-center gap-1.5 border border-ink/15 px-2 py-1 text-[0.6rem] uppercase tracking-[0.18em] text-ink/60 hover:border-sea hover:text-ink"
59
+ >
60
+ <Tag className="size-3" />
61
+ Smart tags
62
+ </button>
63
+ {open ? (
64
+ <>
65
+ <button
66
+ type="button"
67
+ aria-label="Close smart tags"
68
+ className="fixed inset-0 z-40 cursor-default"
69
+ onClick={() => setOpen(false)}
70
+ />
71
+ <div className="absolute right-0 top-full z-50 mt-1 max-h-72 w-72 overflow-auto border border-ink/15 bg-white p-2 shadow-lg">
72
+ {groups.map((g) => (
73
+ <div key={g.title} className="mb-2 last:mb-0">
74
+ <div className="px-1 py-1 text-[0.6rem] uppercase tracking-[0.18em] text-ink/40">
75
+ {g.title}
76
+ </div>
77
+ {g.tags.map((t) => (
78
+ <button
79
+ key={t.tag}
80
+ type="button"
81
+ onClick={() => {
82
+ onInsert(`{{${t.tag}}}`);
83
+ setOpen(false);
84
+ }}
85
+ className="flex w-full items-baseline justify-between gap-2 px-1 py-1 text-left text-xs hover:bg-ice/60"
86
+ >
87
+ <span className="text-ink/80">{t.label}</span>
88
+ <span className="shrink-0 font-mono text-[0.65rem] text-ink/45">
89
+ {`{{${t.tag}}}`}
90
+ </span>
91
+ </button>
92
+ ))}
93
+ </div>
94
+ ))}
95
+ </div>
96
+ </>
97
+ ) : null}
98
+ </div>
99
+ );
100
+ }
101
+
102
+ /** Plain text field (sender name, reply-to…) with a smart tag picker. */
103
+ export function SmartTagTextField({
104
+ label,
105
+ value,
106
+ onChange,
107
+ fields,
108
+ placeholder,
109
+ }: {
110
+ label: string;
111
+ value: string | undefined;
112
+ onChange: (v: string) => void;
113
+ fields: FormFieldDef[];
114
+ placeholder?: string;
115
+ }) {
116
+ return (
117
+ <div className="space-y-1">
118
+ <div className="flex items-end justify-between gap-2">
119
+ <div className="flex-1">
120
+ <TextField label={label} value={value} onChange={onChange} placeholder={placeholder} />
121
+ </div>
122
+ </div>
123
+ <div className="flex justify-end">
124
+ <SmartTags
125
+ fields={fields}
126
+ onInsert={(token) => onChange(`${(value ?? "").trim()} ${token}`.trim())}
127
+ />
128
+ </div>
129
+ </div>
130
+ );
131
+ }
132
+
133
+ /** Localized subject/body field with a smart tag picker per language. */
134
+ export function SmartTagLocalizedField({
135
+ label,
136
+ value,
137
+ onChange,
138
+ fields,
139
+ multiline,
140
+ }: {
141
+ label: string;
142
+ value: Partial<LocalizedString> | undefined;
143
+ onChange: (next: Partial<LocalizedString>) => void;
144
+ fields: FormFieldDef[];
145
+ multiline?: boolean;
146
+ }) {
147
+ const [target, setTarget] = useState<Locale>(SUPPORTED_LOCALES[0] as Locale);
148
+ const v = (value ?? {}) as Record<string, string>;
149
+
150
+ return (
151
+ <div className="space-y-1">
152
+ <LocalizedField label={label} value={value} onChange={onChange} multiline={multiline} />
153
+ <div className="flex justify-end">
154
+ <SmartTags
155
+ fields={fields}
156
+ right={
157
+ SUPPORTED_LOCALES.length > 1 ? (
158
+ <select
159
+ value={target}
160
+ onChange={(e) => setTarget(e.target.value as Locale)}
161
+ className="border border-ink/15 bg-white px-1 py-1 text-[0.6rem] uppercase tracking-[0.18em] text-ink/60"
162
+ >
163
+ {SUPPORTED_LOCALES.map((loc) => (
164
+ <option key={loc} value={loc}>
165
+ {LOCALE_LABELS[loc] ?? loc}
166
+ </option>
167
+ ))}
168
+ </select>
169
+ ) : null
170
+ }
171
+ onInsert={(token) =>
172
+ onChange({
173
+ ...v,
174
+ [target]: `${v[target] ?? ""}${(v[target] ?? "").endsWith(" ") || !v[target] ? "" : " "}${token}`,
175
+ } as Partial<LocalizedString>)
176
+ }
177
+ />
178
+ </div>
179
+ </div>
180
+ );
181
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Client-safe description of what a backup archive can contain.
3
+ *
4
+ * Used by both the backoffice UI and the server-side export/import engine so
5
+ * the two can never drift apart.
6
+ */
7
+
8
+ /**
9
+ * Archive layout version. Format 2 adds accounts, email configuration,
10
+ * verified media and secret sanitising. Format 1 archives are rejected.
11
+ */
12
+ export const ARCHIVE_FORMAT_VERSION = 2;
13
+ /** Bump when the database schema changes in a way that breaks old archives. */
14
+ export const SCHEMA_VERSION = 2;
15
+
16
+ export {
17
+ ALLOWED_MEDIA_EXTENSIONS,
18
+ isAllowedMediaFile,
19
+ hasAllowedMediaExtension,
20
+ verifyMediaFile,
21
+ isSafeArchivePath,
22
+ sanitizeSecrets,
23
+ rewriteMediaUrls,
24
+ neutralizeInjection,
25
+ } from "./safety";
26
+
27
+ export type TableSpec = {
28
+ table: string;
29
+ /** Primary key column(s) used for upserts. */
30
+ pk: string;
31
+ /**
32
+ * Conflict target for upserts. Defaults to `pk`, but tables that carry a
33
+ * business key (pages.key, forms.key, …) must conflict on that instead, or
34
+ * archives with different UUIDs collide on the unique index.
35
+ */
36
+ conflict?: string;
37
+ /**
38
+ * Columns that reference rows created later in the import order.
39
+ * They are stripped on the first pass and written in a second pass.
40
+ */
41
+ deferred?: string[];
42
+ /** Tables that must have imported cleanly before this one is attempted. */
43
+ requires?: string[];
44
+ /** Credential-like values in these JSON columns are stripped on export. */
45
+ sanitize?: string[];
46
+ /**
47
+ * Nullable foreign keys checked against the archive itself. A non-null value
48
+ * whose parent row is absent fails analysis before anything is restored.
49
+ */
50
+ references?: { column: string; table: string; pk?: string }[];
51
+ };
52
+
53
+ /** Business keys used to detect that an archive comes from another database. */
54
+ export const LINEAGE_KEYS: Record<string, string> = {
55
+ pages: "key",
56
+ global_sections: "key",
57
+ forms: "key",
58
+ content_sections: "key",
59
+ page_seo: "page_key",
60
+ redirects: "from_path",
61
+ locale_domains: "locale",
62
+ };
63
+
64
+ export type DatasetGroup = {
65
+ id: string;
66
+ label: string;
67
+ description: string;
68
+ tables: TableSpec[];
69
+ /** Rows are only ever inserted — never updated or deleted. */
70
+ insertOnly?: boolean;
71
+ /** Special payloads that are not plain tables. */
72
+ special?: "mediaFiles" | "authUsers";
73
+ /** Not selected when the user picks "recommended". */
74
+ heavy?: boolean;
75
+ };
76
+
77
+ export const DATASET_GROUPS: DatasetGroup[] = [
78
+ {
79
+ id: "content",
80
+ label: "Content & pages",
81
+ description:
82
+ "Pages, page versions, publications, sections, global sections and block templates.",
83
+ tables: [
84
+ { table: "pages", pk: "id", conflict: "key", deferred: ["current_version_id"] },
85
+ { table: "page_versions", pk: "id", conflict: "page_id,version_no", requires: ["pages"] },
86
+ { table: "page_publications", pk: "id", requires: ["pages", "page_versions"] },
87
+ { table: "content_sections", pk: "key", conflict: "key" },
88
+ { table: "global_sections", pk: "id", conflict: "key", deferred: ["current_version_id"] },
89
+ {
90
+ table: "global_section_versions",
91
+ pk: "id",
92
+ conflict: "section_id,version_no",
93
+ requires: ["global_sections"],
94
+ },
95
+ { table: "block_templates", pk: "id" },
96
+ ],
97
+ },
98
+ {
99
+ id: "seo",
100
+ label: "SEO & routing",
101
+ description: "Page SEO, redirects, language domains and tracking/code injections.",
102
+ tables: [
103
+ { table: "page_seo", pk: "page_key", conflict: "page_key" },
104
+ { table: "redirects", pk: "id", conflict: "from_path" },
105
+ { table: "locale_domains", pk: "locale", conflict: "locale" },
106
+ { table: "site_injections", pk: "id" },
107
+ ],
108
+ },
109
+ {
110
+ id: "forms",
111
+ label: "Forms",
112
+ description: "Form definitions, their version history and global form settings.",
113
+ tables: [
114
+ { table: "forms", pk: "id", conflict: "key" },
115
+ { table: "form_versions", pk: "id", conflict: "form_key,version_no", requires: ["forms"] },
116
+ { table: "form_settings", pk: "id" },
117
+ ],
118
+ },
119
+
120
+ {
121
+ id: "customer",
122
+ label: "Customer data",
123
+ description:
124
+ "Form submissions, partial submissions and delivery attempts. Always insert-only — never overwritten or deleted.",
125
+ insertOnly: true,
126
+ tables: [
127
+ { table: "form_submissions", pk: "id", requires: ["forms"] },
128
+ { table: "form_partials", pk: "id", conflict: "form_key,client_token", requires: ["forms"] },
129
+ {
130
+ table: "form_delivery_attempts",
131
+ pk: "id",
132
+ requires: ["forms", "form_submissions"],
133
+ },
134
+ ],
135
+ },
136
+
137
+ {
138
+ id: "mediaMeta",
139
+ label: "Media metadata",
140
+ description: "Alt texts, titles, descriptions, focus points and the media bin index.",
141
+ tables: [
142
+ { table: "media_meta", pk: "path" },
143
+ { table: "media_focus", pk: "path" },
144
+ { table: "media_trash", pk: "id" },
145
+ ],
146
+ },
147
+ {
148
+ id: "mediaFiles",
149
+ label: "Media files",
150
+ description: "The actual images and videos. This is by far the largest part of an archive.",
151
+ special: "mediaFiles",
152
+ heavy: true,
153
+ tables: [],
154
+ },
155
+ {
156
+ id: "users",
157
+ label: "Users & roles",
158
+ description:
159
+ "Accounts and their role assignments. Passwords can never be exported — restored users must reset theirs.",
160
+ special: "authUsers",
161
+ tables: [
162
+ { table: "app_users", pk: "id" },
163
+ { table: "user_roles", pk: "id", conflict: "user_id,role", requires: ["app_users"] },
164
+ ],
165
+ },
166
+ {
167
+ id: "email",
168
+ label: "Email configuration",
169
+ description:
170
+ "Sending providers and global email settings. Only the names of secrets are stored — never their values.",
171
+ tables: [
172
+ { table: "email_providers", pk: "id", sanitize: ["config", "notes"] },
173
+ {
174
+ table: "email_settings",
175
+ pk: "id",
176
+ requires: ["email_providers"],
177
+ sanitize: ["*"],
178
+ references: [
179
+ { column: "default_provider_id", table: "email_providers" },
180
+ { column: "fallback_provider_id", table: "email_providers" },
181
+ ],
182
+ },
183
+ ],
184
+ },
185
+ {
186
+ id: "logs",
187
+ label: "Logs",
188
+ description: "Audit log and outgoing email history.",
189
+ insertOnly: true,
190
+ heavy: true,
191
+ tables: [
192
+ { table: "audit_logs", pk: "id" },
193
+ { table: "email_logs", pk: "id" },
194
+ ],
195
+ },
196
+ ];
197
+
198
+ export const ALL_GROUP_IDS = DATASET_GROUPS.map((g) => g.id);
199
+ export const DEFAULT_GROUP_IDS = DATASET_GROUPS.filter((g) => !g.heavy).map((g) => g.id);
200
+
201
+ export function groupById(id: string): DatasetGroup | undefined {
202
+ return DATASET_GROUPS.find((g) => g.id === id);
203
+ }
204
+
205
+ /** Import order across all groups, respecting foreign keys. */
206
+ export const IMPORT_ORDER: string[] = [
207
+ "app_users",
208
+ "user_roles",
209
+ "pages",
210
+ "page_versions",
211
+ "page_publications",
212
+ "global_sections",
213
+ "global_section_versions",
214
+ "content_sections",
215
+ "block_templates",
216
+ "page_seo",
217
+ "redirects",
218
+ "locale_domains",
219
+ "site_injections",
220
+ "email_providers",
221
+ "email_settings",
222
+ "forms",
223
+ "form_versions",
224
+ "form_settings",
225
+ "form_submissions",
226
+ "form_partials",
227
+ "form_delivery_attempts",
228
+ "media_meta",
229
+ "media_focus",
230
+ "media_trash",
231
+ "audit_logs",
232
+ "email_logs",
233
+ ];
234
+
235
+ export type ArchiveManifest = {
236
+ format: number;
237
+ schemaVersion: number;
238
+ createdAt: string;
239
+ createdBy: string | null;
240
+ site: string | null;
241
+ groups: string[];
242
+ counts: Record<string, number>;
243
+ mediaFiles: number;
244
+ mediaBytes: number;
245
+ /** Files found in the library but excluded because they are not media. */
246
+ quarantinedMedia?: string[];
247
+ };
248
+
249
+ export type ImportMode = "merge" | "replace";