@drawnagency/primitives 0.1.59 → 0.1.60

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 (81) hide show
  1. package/dist/auth/capabilities.d.ts +16 -0
  2. package/dist/auth/capabilities.d.ts.map +1 -0
  3. package/dist/auth/index.d.ts +2 -0
  4. package/dist/auth/index.d.ts.map +1 -1
  5. package/dist/auth/index.js +3 -1
  6. package/dist/auth/types.d.ts +26 -22
  7. package/dist/auth/types.d.ts.map +1 -1
  8. package/dist/{chunk-ICLXLWQ5.js → chunk-BT7STGDW.js} +31 -23
  9. package/dist/{chunk-L2JJFOXD.js → chunk-CBWK3KPV.js} +5 -1
  10. package/dist/{chunk-V7JN2DDU.js → chunk-ODCJQOVO.js} +1 -1
  11. package/dist/{chunk-NTGSA3TI.js → chunk-Q5EEYBMJ.js} +7 -5
  12. package/dist/chunk-TZQPOR5A.js +24 -0
  13. package/dist/{chunk-XTK4BR27.js → chunk-UNWNT52N.js} +13 -0
  14. package/dist/chunk-ZGFAYZWB.js +8 -0
  15. package/dist/components/editor/DropEdgeIndicator.d.ts +8 -0
  16. package/dist/components/editor/DropEdgeIndicator.d.ts.map +1 -0
  17. package/dist/components/editor/PagesModal.d.ts.map +1 -1
  18. package/dist/components/editor/SectionOrderingModal.d.ts.map +1 -1
  19. package/dist/components/primitives/defineHeadingSection.d.ts +28 -0
  20. package/dist/components/primitives/defineHeadingSection.d.ts.map +1 -0
  21. package/dist/components/sections/Colors/index.d.ts.map +1 -1
  22. package/dist/components/sections/Container/index.d.ts.map +1 -1
  23. package/dist/components/sections/IconList/IconList.d.ts +1 -6
  24. package/dist/components/sections/IconList/IconList.d.ts.map +1 -1
  25. package/dist/components/sections/IconList/index.d.ts +11 -0
  26. package/dist/components/sections/IconList/index.d.ts.map +1 -1
  27. package/dist/components/sections/LinkHeading/index.d.ts.map +1 -1
  28. package/dist/components/sections/SubHeading/index.d.ts +0 -1
  29. package/dist/components/sections/SubHeading/index.d.ts.map +1 -1
  30. package/dist/components/sections/SubSubHeading/index.d.ts +0 -1
  31. package/dist/components/sections/SubSubHeading/index.d.ts.map +1 -1
  32. package/dist/components/sections/all-sections.d.ts +0 -2
  33. package/dist/components/sections/all-sections.d.ts.map +1 -1
  34. package/dist/components/sections/register-schemas.js +409 -420
  35. package/dist/components/shell/BugReportFAB.d.ts.map +1 -1
  36. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  37. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  38. package/dist/hooks/useSortableRow.d.ts +25 -0
  39. package/dist/hooks/useSortableRow.d.ts.map +1 -0
  40. package/dist/index.js +27 -24
  41. package/dist/lib/image-refs.d.ts +21 -0
  42. package/dist/lib/image-refs.d.ts.map +1 -0
  43. package/dist/lib/index.js +8 -7
  44. package/dist/lib/platform-broker.d.ts +14 -0
  45. package/dist/lib/platform-broker.d.ts.map +1 -0
  46. package/dist/lib/platform-broker.js +60 -0
  47. package/dist/lib/reorder.d.ts +12 -0
  48. package/dist/lib/reorder.d.ts.map +1 -0
  49. package/dist/lib/text.d.ts +2 -0
  50. package/dist/lib/text.d.ts.map +1 -1
  51. package/dist/media/index.js +3 -2
  52. package/dist/media/utils.d.ts.map +1 -1
  53. package/dist/schemas/index.js +11 -11
  54. package/dist/types/database.d.ts +46 -0
  55. package/dist/types/database.d.ts.map +1 -1
  56. package/package.json +6 -2
  57. package/src/auth/capabilities.ts +26 -0
  58. package/src/auth/index.ts +3 -0
  59. package/src/auth/types.ts +36 -26
  60. package/src/components/editor/DropEdgeIndicator.tsx +13 -0
  61. package/src/components/editor/PagesModal.tsx +12 -80
  62. package/src/components/editor/SectionOrderingModal.tsx +8 -78
  63. package/src/components/primitives/defineHeadingSection.tsx +56 -0
  64. package/src/components/sections/Colors/index.tsx +2 -4
  65. package/src/components/sections/Container/index.tsx +2 -4
  66. package/src/components/sections/IconList/IconList.tsx +1 -7
  67. package/src/components/sections/IconList/index.tsx +11 -5
  68. package/src/components/sections/LinkHeading/index.tsx +5 -20
  69. package/src/components/sections/SubHeading/index.tsx +6 -23
  70. package/src/components/sections/SubSubHeading/index.tsx +6 -23
  71. package/src/components/shell/BugReportFAB.tsx +13 -9
  72. package/src/hooks/useEditorPublish.ts +71 -49
  73. package/src/hooks/useMediaPipeline.ts +11 -34
  74. package/src/hooks/useSortableRow.ts +99 -0
  75. package/src/lib/image-refs.ts +53 -0
  76. package/src/lib/platform-broker.ts +72 -0
  77. package/src/lib/reorder.ts +20 -0
  78. package/src/lib/text.ts +5 -0
  79. package/src/media/utils.ts +6 -1
  80. package/src/types/database.ts +41 -0
  81. package/dist/chunk-DKOUFIP6.js +0 -35
@@ -213,49 +213,84 @@ export function useEditorPublish({
213
213
  };
214
214
  }
215
215
 
216
- const handleSave = useCallback(async () => {
217
- if (!siteConfig || inFlightRef.current) return;
218
- inFlightRef.current = true;
216
+ // --- Shared save orchestration ---
217
+ // handleSave and handleSaveAndPublish funnel through these so the guard,
218
+ // change-detection, the /api/save request, and the post-save cleanup stay in
219
+ // one place and can't drift between the two paths.
219
220
 
221
+ function tryBeginSave(): boolean {
222
+ if (!siteConfig || inFlightRef.current) return false;
223
+ inFlightRef.current = true;
220
224
  setPublishAction("saving");
221
225
  setPublishFeedback(null);
226
+ return true;
227
+ }
228
+
229
+ async function hasPendingEdits(): Promise<boolean> {
230
+ const hasChanges = await hasLocalChanges();
231
+ const hasMediaChanges = pendingMediaItems.length > 0 || pendingMediaDeletions.length > 0 || manifestDirty;
232
+ const hasDeletedSections = (deletedSectionIds?.length ?? 0) > 0;
233
+ return hasChanges || isConfigDirty() || hasMediaChanges || hasDeletedSections;
234
+ }
235
+
236
+ function runPerformSave() {
237
+ // siteConfig is guaranteed non-null by tryBeginSave, which every caller runs first.
238
+ return performSave({
239
+ siteConfig: siteConfig!,
240
+ siteIndexRef, deletedSectionIds, isConfigDirty,
241
+ pendingMediaItems, pendingMediaDeletions, mediaManifest, manifestDirty,
242
+ });
243
+ }
244
+
245
+ // Clear the Dexie rows + pending media for what was actually committed. In the
246
+ // publish path this MUST run before cacheContent: discardSavedChanges clears
247
+ // only the rows we sent (preserving edits typed during the request) AND
248
+ // invalidates the content cache, which Save & Publish then re-writes against
249
+ // the published sha. clearPendingMediaByIds promotes only media whose blobs
250
+ // were actually written, so items skipped by gatherMediaPayload stay pending
251
+ // (no manifest orphan).
252
+ async function commitSavedRows(
253
+ savedSections: { sectionId: string; updatedAt: string }[],
254
+ uploadedItems: MediaItem[],
255
+ ): Promise<void> {
256
+ await discardSavedChanges(savedSections);
257
+ await clearPendingMediaByIds(uploadedItems.map((i) => i.id), pendingMediaDeletions);
258
+ }
259
+
260
+ function finishSave(uploadedItems: MediaItem[]): void {
261
+ clearConfigDirty();
262
+ clearManifestDirty();
263
+ onSuccess();
264
+ onMediaPublished(uploadedItems, pendingMediaDeletions);
265
+ }
266
+
267
+ function reportSaveError(error: unknown, failLabel: string): void {
268
+ console.error(`${failLabel} failed:`, error);
269
+ const isConflict = (error as { status?: number })?.status === 409;
270
+ showFeedback(isConflict ? (error as Error).message : `${failLabel} failed`, isConflict ? 8000 : 5000);
271
+ }
272
+
273
+ const handleSave = useCallback(async () => {
274
+ if (!tryBeginSave()) return;
222
275
 
223
276
  try {
224
277
  cancelPendingFlush();
225
278
  await flushNow();
226
279
 
227
- const hasChanges = await hasLocalChanges();
228
- const hasMediaChanges = pendingMediaItems.length > 0 || pendingMediaDeletions.length > 0 || manifestDirty;
229
- const hasDeletedSections = (deletedSectionIds?.length ?? 0) > 0;
230
- if (!hasChanges && !isConfigDirty() && !hasMediaChanges && !hasDeletedSections) {
280
+ if (!(await hasPendingEdits())) {
231
281
  setPublishAction("idle");
232
282
  return;
233
283
  }
234
284
 
235
- const { sha, savedSections, uploadedItems } = await performSave({
236
- siteConfig, siteIndexRef, deletedSectionIds,
237
- isConfigDirty, pendingMediaItems, pendingMediaDeletions, mediaManifest, manifestDirty,
238
- });
285
+ const { sha, savedSections, uploadedItems } = await runPerformSave();
239
286
 
240
- // Clears only the section rows we actually sent (preserving edits typed
241
- // during the request) and invalidates contentCache so a reload reflects
242
- // the just-saved content instead of showing the stale pre-save snapshot.
243
- await discardSavedChanges(savedSections);
244
- // Promote/clear only the media that was actually committed. Items skipped
245
- // because their blobs weren't persisted stay pending and retry next save —
246
- // promoting them here would create manifest orphans (no backing file).
247
- await clearPendingMediaByIds(uploadedItems.map((i) => i.id), pendingMediaDeletions);
248
- clearConfigDirty();
249
- clearManifestDirty();
250
- onSuccess();
251
- onMediaPublished(uploadedItems, pendingMediaDeletions);
287
+ await commitSavedRows(savedSections, uploadedItems);
288
+ finishSave(uploadedItems);
252
289
  onShasUpdated(sha, null);
253
290
 
254
291
  showFeedback("Saved", 3000);
255
292
  } catch (error) {
256
- console.error("Save failed:", error);
257
- const isConflict = (error as { status?: number })?.status === 409;
258
- showFeedback(isConflict ? (error as Error).message : "Save failed", isConflict ? 8000 : 5000);
293
+ reportSaveError(error, "Save");
259
294
  } finally {
260
295
  inFlightRef.current = false;
261
296
  setPublishAction("idle");
@@ -298,28 +333,18 @@ export function useEditorPublish({
298
333
  }, [onShasUpdated, showFeedback, onPublishComplete]);
299
334
 
300
335
  const handleSaveAndPublish = useCallback(async () => {
301
- if (!siteConfig || inFlightRef.current) return;
302
- inFlightRef.current = true;
303
-
304
- setPublishAction("saving");
305
- setPublishFeedback(null);
336
+ if (!tryBeginSave()) return;
306
337
 
307
338
  try {
308
339
  cancelPendingFlush();
309
340
  await flushNow();
310
341
 
311
- const hasChanges = await hasLocalChanges();
312
- const hasMediaChanges = pendingMediaItems.length > 0 || pendingMediaDeletions.length > 0 || manifestDirty;
313
- const hasDeletedSections = (deletedSectionIds?.length ?? 0) > 0;
314
- const hasLocalEdits = hasChanges || isConfigDirty() || hasMediaChanges || hasDeletedSections;
342
+ const hasLocalEdits = await hasPendingEdits();
315
343
 
316
344
  let savedSections: { sectionId: string; updatedAt: string }[] = [];
317
345
  let uploadedItems: MediaItem[] = [];
318
346
  if (hasLocalEdits) {
319
- ({ savedSections, uploadedItems } = await performSave({
320
- siteConfig, siteIndexRef, deletedSectionIds,
321
- isConfigDirty, pendingMediaItems, pendingMediaDeletions, mediaManifest, manifestDirty,
322
- }));
347
+ ({ savedSections, uploadedItems } = await runPerformSave());
323
348
  }
324
349
 
325
350
  setPublishAction("publishing");
@@ -341,21 +366,18 @@ export function useEditorPublish({
341
366
  const { sha } = publishData;
342
367
 
343
368
  if (hasLocalEdits) {
344
- await discardSavedChanges(savedSections);
345
- await clearPendingMediaByIds(uploadedItems.map((i) => i.id), pendingMediaDeletions);
346
- await cacheContent(sha, sections, siteIndexRef.current, siteConfig);
347
- clearConfigDirty();
348
- clearManifestDirty();
349
- onSuccess();
350
- onMediaPublished(uploadedItems, pendingMediaDeletions);
369
+ await commitSavedRows(savedSections, uploadedItems);
370
+ // Re-cache against the PUBLISHED sha (commitSavedRows just invalidated the
371
+ // cache); the draft branch is gone after publish, so this is the snapshot a
372
+ // later cache hit restores.
373
+ await cacheContent(sha, sections, siteIndexRef.current, siteConfig!);
374
+ finishSave(uploadedItems);
351
375
  }
352
376
 
353
377
  onShasUpdated(null, sha);
354
378
  onPublishComplete?.();
355
379
  } catch (error) {
356
- console.error("Publish failed:", error);
357
- const isConflict = (error as { status?: number })?.status === 409;
358
- showFeedback(isConflict ? (error as Error).message : "Publish failed", isConflict ? 8000 : 5000);
380
+ reportSaveError(error, "Publish");
359
381
  } finally {
360
382
  inFlightRef.current = false;
361
383
  setPublishAction("idle");
@@ -13,23 +13,7 @@ import {
13
13
  removePendingMediaDeletion,
14
14
  } from "../lib/dexie";
15
15
  import { generateVideoPoster } from "../media/videoPoster";
16
-
17
- function clearImageIds(obj: unknown, ids: Set<string>): unknown {
18
- if (Array.isArray(obj)) return obj.map((item) => clearImageIds(item, ids));
19
- if (obj !== null && typeof obj === "object") {
20
- const record = obj as Record<string, unknown>;
21
- const result: Record<string, unknown> = {};
22
- for (const [key, value] of Object.entries(record)) {
23
- if (key === "imageId" && typeof value === "string" && ids.has(value)) {
24
- result[key] = "";
25
- } else {
26
- result[key] = clearImageIds(value, ids);
27
- }
28
- }
29
- return result;
30
- }
31
- return obj;
32
- }
16
+ import { tallyImageIds, collectImageIds, clearImageIds } from "../lib/image-refs";
33
17
 
34
18
  interface UseMediaPipelineInput {
35
19
  siteConfig: SiteConfig | null;
@@ -253,8 +237,8 @@ export function useMediaPipeline({
253
237
 
254
238
  setSections((prev) =>
255
239
  prev.map((loaded) => {
256
- const json = JSON.stringify(loaded.section);
257
- const hasRef = ids.some((id) => json.includes(`"${id}"`));
240
+ const referenced = collectImageIds(loaded.section);
241
+ const hasRef = ids.some((id) => referenced.has(id));
258
242
  if (!hasRef) return loaded;
259
243
 
260
244
  const cleared = clearImageIds(loaded.section, idSet);
@@ -282,22 +266,15 @@ export function useMediaPipeline({
282
266
  setLocalChangesExist(true);
283
267
  }, [setMediaManifest, setLocalChangesExist]);
284
268
 
269
+ // Count imageId references with a single structural pass per section (O(n)),
270
+ // instead of stringifying every section and regex-scanning it per known id.
271
+ // Counts every referenced imageId; the consumer (MediaLibraryModal) only looks
272
+ // up manifest/pending ids, so extra keys for any dangling references are inert.
285
273
  const referenceCountMap = useMemo((): Record<string, number> => {
286
- const counts: Record<string, number> = {};
287
- const allIds = [
288
- ...Object.keys(mediaManifest.images),
289
- ...pendingMediaItems.map((i) => i.id),
290
- ];
291
- for (const { section } of sections) {
292
- const json = JSON.stringify(section);
293
- for (const id of allIds) {
294
- const regex = new RegExp(`"imageId"\\s*:\\s*"${id}"`, "g");
295
- const matches = json.match(regex);
296
- if (matches) counts[id] = (counts[id] ?? 0) + matches.length;
297
- }
298
- }
299
- return counts;
300
- }, [sections, mediaManifest, pendingMediaItems]);
274
+ const counts = new Map<string, number>();
275
+ for (const { section } of sections) tallyImageIds(section, counts);
276
+ return Object.fromEntries(counts);
277
+ }, [sections]);
301
278
 
302
279
  // --- Unified enqueue ---
303
280
 
@@ -0,0 +1,99 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { computeReorderTarget } from "../lib/reorder";
3
+
4
+ interface UseSortableRowOptions {
5
+ index: number;
6
+ /** Drag-data tag isolating this list from others (e.g. "ordering-row"). */
7
+ dragType: string;
8
+ onReorder: (fromIndex: number, toIndex: number) => void;
9
+ /** When false the row is inert (no drag/drop wiring). Defaults to true. */
10
+ enabled?: boolean;
11
+ }
12
+
13
+ /**
14
+ * Closest-edge drag-to-reorder wiring for a single list row, shared by the
15
+ * section- and page-ordering modals. Attach `rowRef` to the row element and
16
+ * `handleRef` to the drag handle, and render
17
+ * `<DropEdgeIndicator edge={closestEdge} />` for the insertion line. The
18
+ * pointer-position → target-index math lives in `computeReorderTarget`
19
+ * (unit-tested in lib/reorder); the atlaskit adapter is imported lazily so it
20
+ * stays out of the SSR/initial bundle.
21
+ */
22
+ export function useSortableRow<R extends HTMLElement, H extends HTMLElement>({
23
+ index,
24
+ dragType,
25
+ onReorder,
26
+ enabled = true,
27
+ }: UseSortableRowOptions) {
28
+ const rowRef = useRef<R>(null);
29
+ const handleRef = useRef<H>(null);
30
+ const [isDragging, setIsDragging] = useState(false);
31
+ const [closestEdge, setClosestEdge] = useState<"top" | "bottom" | null>(null);
32
+
33
+ useEffect(() => {
34
+ if (!enabled) return;
35
+ const row = rowRef.current;
36
+ const handle = handleRef.current;
37
+ if (!row || !handle) return;
38
+
39
+ let cleanup: (() => void) | undefined;
40
+ let cancelled = false;
41
+
42
+ Promise.all([
43
+ import("@atlaskit/pragmatic-drag-and-drop/element/adapter"),
44
+ import("@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"),
45
+ ]).then(([{ draggable, dropTargetForElements }, { attachClosestEdge, extractClosestEdge }]) => {
46
+ if (cancelled) return;
47
+
48
+ const cleanupDraggable = draggable({
49
+ element: row,
50
+ dragHandle: handle,
51
+ getInitialData: () => ({ dragType, index }),
52
+ onGenerateDragPreview: () => {
53
+ row.style.opacity = "0.4";
54
+ requestAnimationFrame(() => {
55
+ row.style.opacity = "";
56
+ });
57
+ },
58
+ onDragStart: () => setIsDragging(true),
59
+ onDrop: () => setIsDragging(false),
60
+ });
61
+
62
+ const cleanupDropTarget = dropTargetForElements({
63
+ element: row,
64
+ canDrop: ({ source }) => source.data.dragType === dragType,
65
+ getData: ({ input, element }) =>
66
+ attachClosestEdge({ index }, { input, element, allowedEdges: ["top", "bottom"] }),
67
+ onDragEnter: ({ self }) => {
68
+ const edge = extractClosestEdge(self.data);
69
+ setClosestEdge(edge === "top" || edge === "bottom" ? edge : null);
70
+ },
71
+ onDrag: ({ self }) => {
72
+ const edge = extractClosestEdge(self.data);
73
+ setClosestEdge(edge === "top" || edge === "bottom" ? edge : null);
74
+ },
75
+ onDragLeave: () => setClosestEdge(null),
76
+ onDrop: ({ source, self }) => {
77
+ setClosestEdge(null);
78
+ const fromIndex = source.data.index as number;
79
+ const rawEdge = extractClosestEdge(self.data);
80
+ const edge = rawEdge === "top" || rawEdge === "bottom" ? rawEdge : null;
81
+ const toIndex = computeReorderTarget(fromIndex, index, edge);
82
+ if (toIndex !== null) onReorder(fromIndex, toIndex);
83
+ },
84
+ });
85
+
86
+ cleanup = () => {
87
+ cleanupDraggable();
88
+ cleanupDropTarget();
89
+ };
90
+ });
91
+
92
+ return () => {
93
+ cancelled = true;
94
+ cleanup?.();
95
+ };
96
+ }, [index, dragType, onReorder, enabled]);
97
+
98
+ return { rowRef, handleRef, isDragging, closestEdge };
99
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Structural walkers over section content for `imageId` references.
3
+ *
4
+ * These replace the previous JSON.stringify + per-id regex scanning in
5
+ * useMediaPipeline, which was O(sections × ids) and prone to substring
6
+ * false-matches (ids were interpolated into a `RegExp` unescaped, and the delete
7
+ * path's `json.includes('"id"')` matched the id anywhere in the serialized
8
+ * section, not only in an actual `imageId` field). A single structural pass is
9
+ * O(sections) and only ever inspects genuine `imageId` properties.
10
+ */
11
+
12
+ /** Count every non-empty `imageId` string value reachable from `value`. */
13
+ export function tallyImageIds(value: unknown, counts: Map<string, number> = new Map()): Map<string, number> {
14
+ if (Array.isArray(value)) {
15
+ for (const item of value) tallyImageIds(item, counts);
16
+ } else if (value !== null && typeof value === "object") {
17
+ for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
18
+ if (key === "imageId" && typeof v === "string" && v !== "") {
19
+ counts.set(v, (counts.get(v) ?? 0) + 1);
20
+ } else {
21
+ tallyImageIds(v, counts);
22
+ }
23
+ }
24
+ }
25
+ return counts;
26
+ }
27
+
28
+ /** The set of distinct non-empty `imageId`s reachable from `value`. */
29
+ export function collectImageIds(value: unknown): Set<string> {
30
+ return new Set(tallyImageIds(value).keys());
31
+ }
32
+
33
+ /**
34
+ * Return a deep copy of `value` with every `imageId` whose value is in `ids`
35
+ * reset to "" (used when the referenced media is deleted). All other content,
36
+ * including arrays and nesting, is preserved unchanged.
37
+ */
38
+ export function clearImageIds(value: unknown, ids: Set<string>): unknown {
39
+ if (Array.isArray(value)) return value.map((item) => clearImageIds(item, ids));
40
+ if (value !== null && typeof value === "object") {
41
+ const record = value as Record<string, unknown>;
42
+ const result: Record<string, unknown> = {};
43
+ for (const [key, v] of Object.entries(record)) {
44
+ if (key === "imageId" && typeof v === "string" && ids.has(v)) {
45
+ result[key] = "";
46
+ } else {
47
+ result[key] = clearImageIds(v, ids);
48
+ }
49
+ }
50
+ return result;
51
+ }
52
+ return value;
53
+ }
@@ -0,0 +1,72 @@
1
+ import { env } from "./env";
2
+
3
+ const ATTEMPTS = 3;
4
+ const RETRY_BASE_MS = 150;
5
+
6
+ function sleep(ms: number): Promise<void> {
7
+ return new Promise((resolve) => setTimeout(resolve, ms));
8
+ }
9
+
10
+ /** True when this runtime should broker privileged ops through the platform. */
11
+ export function isPlatformMode(): boolean {
12
+ return Boolean(env("PLATFORM_API_URL") && env("PLATFORM_API_KEY"));
13
+ }
14
+
15
+ /**
16
+ * Call a platform broker endpoint scoped to this site. The platform authorizes
17
+ * the op against the site that owns PLATFORM_API_KEY — the client never names
18
+ * another tenant. Retries network/5xx (transient); 4xx is a real auth/config
19
+ * error and is surfaced immediately. Mirrors fetchInstallationToken().
20
+ */
21
+ export async function platformBroker<T = unknown>(
22
+ path: string,
23
+ opts: { method?: string; body?: unknown; query?: Record<string, string> } = {},
24
+ ): Promise<T> {
25
+ const apiUrl = env("PLATFORM_API_URL");
26
+ const apiKey = env("PLATFORM_API_KEY");
27
+ const siteId = env("PORTAL_SITE_ID");
28
+
29
+ const method = opts.method ?? (opts.body !== undefined ? "POST" : "GET");
30
+ const qs = opts.query ? "?" + new URLSearchParams(opts.query).toString() : "";
31
+ const url = `${apiUrl}${path}${qs}`;
32
+
33
+ const headers: Record<string, string> = { "x-api-key": apiKey, "x-site-id": siteId };
34
+ let serializedBody: string | undefined;
35
+ if (opts.body !== undefined) {
36
+ headers["Content-Type"] = "application/json";
37
+ serializedBody = JSON.stringify(opts.body);
38
+ }
39
+
40
+ let lastError: unknown;
41
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
42
+ const isLast = attempt === ATTEMPTS - 1;
43
+ let res: Response;
44
+ try {
45
+ res = await fetch(url, { method, headers, body: serializedBody });
46
+ } catch (err) {
47
+ lastError = err;
48
+ if (isLast) throw err;
49
+ await sleep(RETRY_BASE_MS * 2 ** attempt);
50
+ continue;
51
+ }
52
+
53
+ if (res.ok) {
54
+ if (res.status === 204) return undefined as T;
55
+ try {
56
+ return (await res.json()) as T;
57
+ } catch {
58
+ return undefined as T;
59
+ }
60
+ }
61
+
62
+ if (res.status >= 500 && !isLast) {
63
+ lastError = new Error(`Platform broker error (${res.status})`);
64
+ await sleep(RETRY_BASE_MS * 2 ** attempt);
65
+ continue;
66
+ }
67
+
68
+ throw new Error(`Platform broker error (${res.status}): ${await res.text()}`);
69
+ }
70
+
71
+ throw lastError instanceof Error ? lastError : new Error("Platform broker unreachable");
72
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Compute the destination index for a closest-edge drag-to-reorder drop.
3
+ *
4
+ * `overIndex` is the index of the row the pointer is over; `edge` is which half
5
+ * of that row the pointer is nearest ("bottom" inserts after it, "top"/null
6
+ * before it). The `fromIndex < toIndex` decrement accounts for the dragged row
7
+ * being removed from its original slot before re-insertion.
8
+ *
9
+ * Returns the target index, or `null` when the drop would not move the row.
10
+ */
11
+ export function computeReorderTarget(
12
+ fromIndex: number,
13
+ overIndex: number,
14
+ edge: "top" | "bottom" | null,
15
+ ): number | null {
16
+ let toIndex = overIndex;
17
+ if (edge === "bottom") toIndex = overIndex + 1;
18
+ if (fromIndex < toIndex) toIndex--;
19
+ return fromIndex === toIndex ? null : toIndex;
20
+ }
package/src/lib/text.ts CHANGED
@@ -10,3 +10,8 @@ export function truncate(text: string, maxLength: number): string {
10
10
  if (text.length <= maxLength) return text;
11
11
  return text.slice(0, maxLength) + "...";
12
12
  }
13
+
14
+ /** "1 item" / "0 items" / "3 items" — the count plus a naive English plural of `word`. */
15
+ export function pluralize(n: number, word: string): string {
16
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
17
+ }
@@ -1,3 +1,5 @@
1
+ import { slugifyPageSlug } from "../lib/pages";
2
+
1
3
  export async function hashFileBuffer(buffer: ArrayBuffer): Promise<string> {
2
4
  if (typeof crypto !== "undefined" && crypto.subtle) {
3
5
  const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
@@ -21,8 +23,11 @@ export async function hashFileBuffer(buffer: ArrayBuffer): Promise<string> {
21
23
  return result;
22
24
  }
23
25
 
26
+ // Strip the file extension, then apply the canonical slug transform. Equivalent
27
+ // to the previous inline regex (verified by fuzz + the boundary tests in
28
+ // tests/lib/media/utils.test.ts); shares one slugifier with page slugs.
24
29
  export function sanitizeMediaName(name: string): string {
25
- return name.replace(/\.[^.]+$/, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
30
+ return slugifyPageSlug(name.replace(/\.[^.]+$/, ""));
26
31
  }
27
32
 
28
33
  export const MIME_TO_EXT: Record<string, string> = {
@@ -419,6 +419,33 @@ export type Database = {
419
419
  },
420
420
  ]
421
421
  }
422
+ rate_limits: {
423
+ Row: {
424
+ site_id: string
425
+ limiter: string
426
+ key: string
427
+ window_start: string
428
+ count: number
429
+ updated_at: string
430
+ }
431
+ Insert: {
432
+ site_id: string
433
+ limiter: string
434
+ key: string
435
+ window_start?: string
436
+ count?: number
437
+ updated_at?: string
438
+ }
439
+ Update: {
440
+ site_id?: string
441
+ limiter?: string
442
+ key?: string
443
+ window_start?: string
444
+ count?: number
445
+ updated_at?: string
446
+ }
447
+ Relationships: []
448
+ }
422
449
  }
423
450
  Views: {
424
451
  [_ in never]: never
@@ -431,6 +458,20 @@ export type Database = {
431
458
  Args: { p_site_id: string; p_user_id: string }
432
459
  Returns: undefined
433
460
  }
461
+ rate_limit_hit: {
462
+ Args: {
463
+ p_site_id: string
464
+ p_limiter: string
465
+ p_key: string
466
+ p_window_ms: number
467
+ p_max: number
468
+ }
469
+ Returns: { allowed: boolean; retry_after_ms: number }[]
470
+ }
471
+ rate_limit_cleanup: {
472
+ Args: { p_max_age_ms: number }
473
+ Returns: undefined
474
+ }
434
475
  }
435
476
  Enums: {
436
477
  [_ in never]: never
@@ -1,35 +0,0 @@
1
- // src/schemas/media.ts
2
- import { z } from "zod";
3
- var VariantSchema = z.object({
4
- width: z.number().int().positive(),
5
- height: z.number().int().positive(),
6
- size: z.number().int().nonnegative()
7
- });
8
- var MediaItemSchema = z.object({
9
- id: z.string(),
10
- hash: z.string(),
11
- kind: z.enum(["image", "animated", "video"]),
12
- originalName: z.string(),
13
- width: z.number().int().positive(),
14
- height: z.number().int().positive(),
15
- mimeType: z.string(),
16
- size: z.number().int().nonnegative(),
17
- folder: z.string(),
18
- variants: z.array(VariantSchema),
19
- alt: z.string().default("")
20
- });
21
- var ImageManifestSchema = z.object({
22
- images: z.record(z.string(), MediaItemSchema)
23
- });
24
- var MediaConfigSchema = z.object({
25
- sizes: z.array(z.number()).default([640, 1080, 1920]),
26
- maxFileSize: z.number().default(5242880),
27
- quality: z.number().min(1).max(100).default(85)
28
- });
29
-
30
- export {
31
- VariantSchema,
32
- MediaItemSchema,
33
- ImageManifestSchema,
34
- MediaConfigSchema
35
- };