@bygghammar/cms-platform 0.46.1 → 0.47.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/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.47.0
4
+
5
+ - Media server logic is package-owned: new `server/media-focus-core` (focus-point map, desktop/mobile upsert, clear), `server/media-meta-core` (localized alt/title/description/credit read and write) and `server/media-replace-core` (width-matched "replace everywhere" with audit trail).
6
+ - New `server/media-cleanup-host`: `createSupabaseCleanupHost()` assembles the bin rows, `media_meta`/`media_focus` path moves and the cleanup audit entry, so a site only supplies its privileged client, storage and reference rewriter.
7
+
3
8
  ## 0.46.1
4
9
 
5
10
  - Page builder host contract: `renderEditor` now also receives `initialBlocks` (the server baseline) alongside the live canvas.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.46.1",
3
+ "version": "0.47.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,
@@ -166,7 +166,11 @@
166
166
  "./backoffice/ResetPasswordView": "./src/backoffice/ResetPasswordView.tsx",
167
167
  "./backoffice/routes/pages": "./src/backoffice/routes/pages.tsx",
168
168
  "./config/define-site": "./src/config/define-site.ts",
169
- "./server/schema-drift": "./src/server/schema-drift.ts"
169
+ "./server/schema-drift": "./src/server/schema-drift.ts",
170
+ "./server/media-focus-core": "./src/server/media-focus-core.ts",
171
+ "./server/media-meta-core": "./src/server/media-meta-core.ts",
172
+ "./server/media-replace-core": "./src/server/media-replace-core.ts",
173
+ "./server/media-cleanup-host": "./src/server/media-cleanup-host.ts"
170
174
  },
171
175
  "dependencies": {
172
176
  "fflate": "^0.8.3"
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Supabase-backed `CleanupHost` for the media cleanup core.
3
+ *
4
+ * Every site stores media bookkeeping in the same three tables (`media_trash`,
5
+ * `media_meta`, `media_focus`) and the same `audit_logs` table, so the database
6
+ * side effects the cleanup core needs are platform logic, not site logic. A host
7
+ * supplies its privileged client, its storage provider and its reference
8
+ * rewriter; this factory assembles the rest.
9
+ */
10
+ import { actorEmail, type PlatformAuthContext } from "./guards";
11
+ import type { CleanupHost, CleanupStorage, MediaCleanupResult } from "./media-cleanup-core";
12
+
13
+ /* eslint-disable @typescript-eslint/no-explicit-any */
14
+
15
+ const PATH_TABLES = ["media_meta", "media_focus"] as const;
16
+
17
+ export type CleanupHostDeps = {
18
+ context: PlatformAuthContext;
19
+ /** Privileged client used for the bin rows, path moves and the audit entry. */
20
+ admin: any;
21
+ /** Storage bound to the privileged client (usually `getMediaStorage(admin)`). */
22
+ storage: CleanupStorage;
23
+ /** Repoint stored references from any of `oldUrls` to `newUrl`; returns hits. */
24
+ rewriteReferences: (oldUrls: string[], newUrl: string) => Promise<number>;
25
+ };
26
+
27
+ export function createSupabaseCleanupHost(deps: CleanupHostDeps): CleanupHost {
28
+ const { admin, context } = deps;
29
+
30
+ return {
31
+ storage: deps.storage,
32
+ rewriteReferences: deps.rewriteReferences,
33
+
34
+ trashDuplicate: async ({ trashId, path, trashPrefix, size, mergedInto }) => {
35
+ await admin.from("media_trash").insert({
36
+ id: trashId,
37
+ original_path: path,
38
+ paths: [path],
39
+ trash_prefix: trashPrefix,
40
+ size_bytes: size,
41
+ content_type: null,
42
+ deleted_by: context.userId,
43
+ deleted_by_email: actorEmail(context),
44
+ usage_snapshot: { mergedInto },
45
+ });
46
+ for (const table of PATH_TABLES) {
47
+ await admin.from(table).delete().eq("path", path);
48
+ }
49
+ },
50
+
51
+ movePathRows: async (from: string, to: string) => {
52
+ for (const table of PATH_TABLES) {
53
+ // A row already sitting on the destination path wins; drop the stale one
54
+ // instead of violating the unique path constraint.
55
+ const { data: existing } = await admin
56
+ .from(table)
57
+ .select("path")
58
+ .eq("path", to)
59
+ .maybeSingle();
60
+ if (existing) {
61
+ await admin.from(table).delete().eq("path", from);
62
+ continue;
63
+ }
64
+ await admin.from(table).update({ path: to }).eq("path", from);
65
+ }
66
+ },
67
+
68
+ logCleanup: async (result: MediaCleanupResult) => {
69
+ await admin.from("audit_logs").insert({
70
+ actor_id: context.userId,
71
+ actor_email: actorEmail(context),
72
+ action: "media.cleanup",
73
+ entity: "media",
74
+ entity_id: "bulk",
75
+ meta: result,
76
+ });
77
+ },
78
+ };
79
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Host-agnostic focus-point (focal crop) server logic.
3
+ *
4
+ * Owns the whole `media_focus` lifecycle: the public read of every stored
5
+ * point, the desktop/mobile upsert and the clear. Hosts supply a privileged
6
+ * client for the public read (the table is staff-only through row-level
7
+ * security, but the coordinates themselves are needed to render the site) and
8
+ * their authenticated request context for the writes, so RLS applies as the
9
+ * signed-in user.
10
+ */
11
+ import { assertStaff, type PlatformAuthContext } from "./guards";
12
+ import { clampFocal, focusKey, type FocusDevice, type FocusMap } from "../media/focus";
13
+
14
+ /* eslint-disable @typescript-eslint/no-explicit-any */
15
+
16
+ /** Privileged-client factory the public read needs. */
17
+ export type FocusAdmin = () => Promise<any>;
18
+
19
+ /** Authenticated request context the writes run under. */
20
+ export type FocusContext = PlatformAuthContext & { supabase: any };
21
+
22
+ export type SetFocusInput = { src: string; x: number; y: number; device?: FocusDevice };
23
+ export type ClearFocusInput = { src: string; device?: FocusDevice };
24
+
25
+ export type FocusWriteResult = {
26
+ ok: true;
27
+ path?: string;
28
+ device?: FocusDevice;
29
+ x?: number;
30
+ y?: number;
31
+ };
32
+
33
+ /**
34
+ * Every stored focus point, keyed by canonical media path. Returns an empty map
35
+ * rather than throwing so a failed read never breaks page rendering.
36
+ */
37
+ export async function getMediaFocusMapCore(admin: FocusAdmin): Promise<FocusMap> {
38
+ const db = await admin();
39
+ const { data, error } = await db
40
+ .from("media_focus")
41
+ .select("path, focal_x, focal_y, mobile_x, mobile_y");
42
+ if (error) return {};
43
+
44
+ const map: FocusMap = {};
45
+ for (const row of (data ?? []) as {
46
+ path: string;
47
+ focal_x: number;
48
+ focal_y: number;
49
+ mobile_x: number | null;
50
+ mobile_y: number | null;
51
+ }[]) {
52
+ map[row.path] = {
53
+ x: row.focal_x,
54
+ y: row.focal_y,
55
+ mobile:
56
+ row.mobile_x !== null && row.mobile_y !== null
57
+ ? { x: row.mobile_x, y: row.mobile_y }
58
+ : null,
59
+ };
60
+ }
61
+ return map;
62
+ }
63
+
64
+ /**
65
+ * Store a focus point. A mobile point is an override: the existing desktop
66
+ * coordinates are preserved (defaulting to dead centre) so switching device
67
+ * never silently recentres the other breakpoint.
68
+ */
69
+ export async function setMediaFocusCore(
70
+ context: FocusContext,
71
+ data: SetFocusInput,
72
+ ): Promise<FocusWriteResult> {
73
+ await assertStaff(context);
74
+
75
+ const path = focusKey(data.src);
76
+ if (!path) throw new Error("Missing image path");
77
+ const point = clampFocal({ x: data.x, y: data.y });
78
+ const device: FocusDevice = data.device === "mobile" ? "mobile" : "desktop";
79
+
80
+ if (device === "mobile") {
81
+ const { data: existing } = await context.supabase
82
+ .from("media_focus")
83
+ .select("focal_x, focal_y")
84
+ .eq("path", path)
85
+ .maybeSingle();
86
+ const { error } = await context.supabase.from("media_focus").upsert(
87
+ {
88
+ path,
89
+ focal_x: existing?.focal_x ?? 50,
90
+ focal_y: existing?.focal_y ?? 50,
91
+ mobile_x: point.x,
92
+ mobile_y: point.y,
93
+ updated_by: context.userId,
94
+ },
95
+ { onConflict: "path" },
96
+ );
97
+ if (error) throw new Error(error.message);
98
+ return { ok: true, path, device, ...point };
99
+ }
100
+
101
+ const { error } = await context.supabase
102
+ .from("media_focus")
103
+ .upsert(
104
+ { path, focal_x: point.x, focal_y: point.y, updated_by: context.userId },
105
+ { onConflict: "path" },
106
+ );
107
+ if (error) throw new Error(error.message);
108
+ return { ok: true, path, device, ...point };
109
+ }
110
+
111
+ /**
112
+ * Drop a focus point. Clearing mobile only nulls the override; clearing desktop
113
+ * removes the row so the image falls back to a centred crop.
114
+ */
115
+ export async function clearMediaFocusCore(
116
+ context: FocusContext,
117
+ data: ClearFocusInput,
118
+ ): Promise<FocusWriteResult> {
119
+ await assertStaff(context);
120
+
121
+ const path = focusKey(data.src);
122
+ if (!path) return { ok: true };
123
+
124
+ if (data.device === "mobile") {
125
+ const { error } = await context.supabase
126
+ .from("media_focus")
127
+ .update({ mobile_x: null, mobile_y: null, updated_by: context.userId })
128
+ .eq("path", path);
129
+ if (error) throw new Error(error.message);
130
+ return { ok: true };
131
+ }
132
+
133
+ const { error } = await context.supabase.from("media_focus").delete().eq("path", path);
134
+ if (error) throw new Error(error.message);
135
+ return { ok: true };
136
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Host-agnostic media metadata server logic (alt / title / description /
3
+ * credit).
4
+ *
5
+ * Alt text, title and description are localized maps so every language carries
6
+ * its own copy; metadata is keyed by the same canonical, variant-collapsed path
7
+ * as focus points. The public read uses a privileged client because the table is
8
+ * staff-only through row-level security while the text itself is rendered on the
9
+ * public site; writes run under the host's authenticated context.
10
+ */
11
+ import { assertStaff, type PlatformAuthContext } from "./guards";
12
+ import {
13
+ cleanLocalized,
14
+ metaKey,
15
+ type LocalizedText,
16
+ type MediaMetaMap,
17
+ } from "../media/media-meta";
18
+
19
+ /* eslint-disable @typescript-eslint/no-explicit-any */
20
+
21
+ export type MediaMetaAdmin = () => Promise<any>;
22
+ export type MediaMetaContext = PlatformAuthContext & { supabase: any };
23
+
24
+ export type SaveMediaMetaInput = {
25
+ src: string;
26
+ alt?: LocalizedText;
27
+ title?: LocalizedText;
28
+ description?: LocalizedText;
29
+ credit?: string;
30
+ };
31
+
32
+ /** All stored metadata, keyed by canonical path. Never throws. */
33
+ export async function getMediaMetaMapCore(admin: MediaMetaAdmin): Promise<MediaMetaMap> {
34
+ const db = await admin();
35
+ const { data, error } = await db
36
+ .from("media_meta")
37
+ .select("path, alt, title, description, credit");
38
+ if (error) return {};
39
+
40
+ const map: MediaMetaMap = {};
41
+ for (const row of (data ?? []) as {
42
+ path: string;
43
+ alt: LocalizedText | null;
44
+ title: LocalizedText | null;
45
+ description: LocalizedText | null;
46
+ credit: string | null;
47
+ }[]) {
48
+ map[row.path] = {
49
+ alt: row.alt ?? {},
50
+ title: row.title ?? {},
51
+ description: row.description ?? {},
52
+ credit: row.credit ?? "",
53
+ };
54
+ }
55
+ return map;
56
+ }
57
+
58
+ /** Upsert metadata for one asset; empty locales are stripped before storing. */
59
+ export async function setMediaMetaCore(
60
+ context: MediaMetaContext,
61
+ data: SaveMediaMetaInput,
62
+ ): Promise<{ ok: true; path: string }> {
63
+ await assertStaff(context);
64
+
65
+ const path = metaKey(data.src);
66
+ if (!path) throw new Error("Missing image path");
67
+
68
+ const { error } = await context.supabase.from("media_meta").upsert(
69
+ {
70
+ path,
71
+ alt: cleanLocalized(data.alt),
72
+ title: cleanLocalized(data.title),
73
+ description: cleanLocalized(data.description),
74
+ credit: (data.credit ?? "").trim(),
75
+ updated_by: context.userId,
76
+ },
77
+ { onConflict: "path" },
78
+ );
79
+ if (error) throw new Error(error.message);
80
+ return { ok: true, path };
81
+ }
82
+
83
+ /** Remove all metadata for one asset. */
84
+ export async function clearMediaMetaCore(
85
+ context: MediaMetaContext,
86
+ data: { src: string },
87
+ ): Promise<{ ok: true }> {
88
+ await assertStaff(context);
89
+
90
+ const path = metaKey(data.src);
91
+ if (!path) return { ok: true };
92
+ const { error } = await context.supabase.from("media_meta").delete().eq("path", path);
93
+ if (error) throw new Error(error.message);
94
+ return { ok: true };
95
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Host-agnostic "replace this file everywhere" logic.
3
+ *
4
+ * Swaps one library asset for another across every stored CMS reference.
5
+ * Responsive variants are matched width-for-width so srcSets stay consistent;
6
+ * anything without a matching width falls back to the new primary file.
7
+ *
8
+ * Nothing is deleted — the old asset stays in the library, so a wrong replace is
9
+ * reversed by replacing back.
10
+ */
11
+ import { assertStaff, actorEmail, type PlatformAuthContext } from "./guards";
12
+ import { RESPONSIVE_WIDTHS, variantSiblings } from "../media/srcset";
13
+
14
+ /* eslint-disable @typescript-eslint/no-explicit-any */
15
+
16
+ const VARIANT_RE = /-(480|768|1200|1920|2560)\.webp$/i;
17
+
18
+ function variantBase(path: string): string | null {
19
+ const m = path.match(VARIANT_RE);
20
+ return m ? path.slice(0, path.length - m[0].length) : null;
21
+ }
22
+
23
+ /** Every storage path belonging to one asset (primary + responsive siblings). */
24
+ function family(path: string): string[] {
25
+ return [path, ...variantSiblings(path)];
26
+ }
27
+
28
+ /** Storage surface the replace needs — satisfied by `getMediaStorage()`. */
29
+ export type ReplaceStorage = {
30
+ list: () => Promise<{ name: string }[]>;
31
+ publicUrl: (path: string) => string;
32
+ };
33
+
34
+ export type ReplaceDeps = {
35
+ context: PlatformAuthContext & { supabase: any };
36
+ /** Privileged client used for the reference rewrite and the audit row. */
37
+ admin: () => Promise<any>;
38
+ /** Storage bound to the privileged client. */
39
+ storage: (admin: any) => ReplaceStorage;
40
+ /** Repoint stored references from any of `oldUrls` to `newUrl`; returns hits. */
41
+ rewriteReferences: (admin: any, oldUrls: string[], newUrl: string) => Promise<number>;
42
+ };
43
+
44
+ export type ReplaceResult = { rewritten: number; widths: number };
45
+
46
+ export async function replaceMediaEverywhereCore(
47
+ deps: ReplaceDeps,
48
+ data: { fromPath: string; toPath: string },
49
+ ): Promise<ReplaceResult> {
50
+ await assertStaff(deps.context);
51
+ if (!data.fromPath || !data.toPath) throw new Error("Both files are required");
52
+ if (data.fromPath === data.toPath) throw new Error("Pick two different files");
53
+
54
+ const admin = await deps.admin();
55
+ const storage = deps.storage(admin);
56
+
57
+ const existing = new Set((await storage.list()).map((f) => f.name));
58
+ if (!existing.has(data.fromPath)) throw new Error("Source file not found");
59
+ if (!existing.has(data.toPath)) throw new Error("Replacement file not found");
60
+
61
+ const newBase = variantBase(data.toPath);
62
+ const newPrimaryUrl = storage.publicUrl(data.toPath);
63
+
64
+ // Most specific first: width-matched variants, then the primary path.
65
+ const pairs: { oldPath: string; newUrl: string }[] = [];
66
+ for (const oldPath of family(data.fromPath)) {
67
+ if (!existing.has(oldPath) && oldPath !== data.fromPath) continue;
68
+ const width = oldPath.match(VARIANT_RE)?.[1];
69
+ let target = newPrimaryUrl;
70
+ if (width && newBase) {
71
+ const candidate = `${newBase}-${width}.webp`;
72
+ if (existing.has(candidate)) target = storage.publicUrl(candidate);
73
+ }
74
+ pairs.push({ oldPath, newUrl: target });
75
+ }
76
+ pairs.sort((a, b) => b.oldPath.length - a.oldPath.length);
77
+
78
+ let rewritten = 0;
79
+ for (const pair of pairs) {
80
+ const url = storage.publicUrl(pair.oldPath);
81
+ if (!url || url === pair.newUrl) continue;
82
+ const candidates = Array.from(
83
+ new Set([url, encodeURI(url), decodeURI(url), `/${pair.oldPath}`]),
84
+ );
85
+ rewritten += await deps.rewriteReferences(admin, candidates, pair.newUrl);
86
+ }
87
+
88
+ try {
89
+ await admin.from("audit_logs").insert({
90
+ actor_id: deps.context.userId,
91
+ actor_email: actorEmail(deps.context),
92
+ action: "media.replace_everywhere",
93
+ entity: "media",
94
+ entity_id: data.fromPath,
95
+ meta: { to: data.toPath, rewritten },
96
+ });
97
+ } catch (e) {
98
+ console.warn("audit failed", e);
99
+ }
100
+
101
+ return { rewritten, widths: RESPONSIVE_WIDTHS.length };
102
+ }