@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.
- package/dist/auth/capabilities.d.ts +16 -0
- package/dist/auth/capabilities.d.ts.map +1 -0
- package/dist/auth/index.d.ts +2 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +3 -1
- package/dist/auth/types.d.ts +26 -22
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/{chunk-ICLXLWQ5.js → chunk-BT7STGDW.js} +31 -23
- package/dist/{chunk-L2JJFOXD.js → chunk-CBWK3KPV.js} +5 -1
- package/dist/{chunk-V7JN2DDU.js → chunk-ODCJQOVO.js} +1 -1
- package/dist/{chunk-NTGSA3TI.js → chunk-Q5EEYBMJ.js} +7 -5
- package/dist/chunk-TZQPOR5A.js +24 -0
- package/dist/{chunk-XTK4BR27.js → chunk-UNWNT52N.js} +13 -0
- package/dist/chunk-ZGFAYZWB.js +8 -0
- package/dist/components/editor/DropEdgeIndicator.d.ts +8 -0
- package/dist/components/editor/DropEdgeIndicator.d.ts.map +1 -0
- package/dist/components/editor/PagesModal.d.ts.map +1 -1
- package/dist/components/editor/SectionOrderingModal.d.ts.map +1 -1
- package/dist/components/primitives/defineHeadingSection.d.ts +28 -0
- package/dist/components/primitives/defineHeadingSection.d.ts.map +1 -0
- package/dist/components/sections/Colors/index.d.ts.map +1 -1
- package/dist/components/sections/Container/index.d.ts.map +1 -1
- package/dist/components/sections/IconList/IconList.d.ts +1 -6
- package/dist/components/sections/IconList/IconList.d.ts.map +1 -1
- package/dist/components/sections/IconList/index.d.ts +11 -0
- package/dist/components/sections/IconList/index.d.ts.map +1 -1
- package/dist/components/sections/LinkHeading/index.d.ts.map +1 -1
- package/dist/components/sections/SubHeading/index.d.ts +0 -1
- package/dist/components/sections/SubHeading/index.d.ts.map +1 -1
- package/dist/components/sections/SubSubHeading/index.d.ts +0 -1
- package/dist/components/sections/SubSubHeading/index.d.ts.map +1 -1
- package/dist/components/sections/all-sections.d.ts +0 -2
- package/dist/components/sections/all-sections.d.ts.map +1 -1
- package/dist/components/sections/register-schemas.js +409 -420
- package/dist/components/shell/BugReportFAB.d.ts.map +1 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/hooks/useSortableRow.d.ts +25 -0
- package/dist/hooks/useSortableRow.d.ts.map +1 -0
- package/dist/index.js +27 -24
- package/dist/lib/image-refs.d.ts +21 -0
- package/dist/lib/image-refs.d.ts.map +1 -0
- package/dist/lib/index.js +8 -7
- package/dist/lib/platform-broker.d.ts +14 -0
- package/dist/lib/platform-broker.d.ts.map +1 -0
- package/dist/lib/platform-broker.js +60 -0
- package/dist/lib/reorder.d.ts +12 -0
- package/dist/lib/reorder.d.ts.map +1 -0
- package/dist/lib/text.d.ts +2 -0
- package/dist/lib/text.d.ts.map +1 -1
- package/dist/media/index.js +3 -2
- package/dist/media/utils.d.ts.map +1 -1
- package/dist/schemas/index.js +11 -11
- package/dist/types/database.d.ts +46 -0
- package/dist/types/database.d.ts.map +1 -1
- package/package.json +6 -2
- package/src/auth/capabilities.ts +26 -0
- package/src/auth/index.ts +3 -0
- package/src/auth/types.ts +36 -26
- package/src/components/editor/DropEdgeIndicator.tsx +13 -0
- package/src/components/editor/PagesModal.tsx +12 -80
- package/src/components/editor/SectionOrderingModal.tsx +8 -78
- package/src/components/primitives/defineHeadingSection.tsx +56 -0
- package/src/components/sections/Colors/index.tsx +2 -4
- package/src/components/sections/Container/index.tsx +2 -4
- package/src/components/sections/IconList/IconList.tsx +1 -7
- package/src/components/sections/IconList/index.tsx +11 -5
- package/src/components/sections/LinkHeading/index.tsx +5 -20
- package/src/components/sections/SubHeading/index.tsx +6 -23
- package/src/components/sections/SubSubHeading/index.tsx +6 -23
- package/src/components/shell/BugReportFAB.tsx +13 -9
- package/src/hooks/useEditorPublish.ts +71 -49
- package/src/hooks/useMediaPipeline.ts +11 -34
- package/src/hooks/useSortableRow.ts +99 -0
- package/src/lib/image-refs.ts +53 -0
- package/src/lib/platform-broker.ts +72 -0
- package/src/lib/reorder.ts +20 -0
- package/src/lib/text.ts +5 -0
- package/src/media/utils.ts +6 -1
- package/src/types/database.ts +41 -0
- package/dist/chunk-DKOUFIP6.js +0 -35
|
@@ -213,49 +213,84 @@ export function useEditorPublish({
|
|
|
213
213
|
};
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
|
236
|
-
siteConfig, siteIndexRef, deletedSectionIds,
|
|
237
|
-
isConfigDirty, pendingMediaItems, pendingMediaDeletions, mediaManifest, manifestDirty,
|
|
238
|
-
});
|
|
285
|
+
const { sha, savedSections, uploadedItems } = await runPerformSave();
|
|
239
286
|
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
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
|
|
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
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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
|
|
257
|
-
const hasRef = ids.some((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
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
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
|
+
}
|
package/src/media/utils.ts
CHANGED
|
@@ -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(/\.[^.]+$/, "")
|
|
30
|
+
return slugifyPageSlug(name.replace(/\.[^.]+$/, ""));
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
export const MIME_TO_EXT: Record<string, string> = {
|
package/src/types/database.ts
CHANGED
|
@@ -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
|
package/dist/chunk-DKOUFIP6.js
DELETED
|
@@ -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
|
-
};
|