@drawnagency/primitives 0.1.93 → 0.1.95
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/{chunk-UFOWXOG6.js → chunk-ABV7OZB3.js} +6 -0
- package/dist/{chunk-LQQY2RW6.js → chunk-DJAJRBPD.js} +1 -0
- package/dist/{chunk-677NTHAZ.js → chunk-PTOLUMGV.js} +3 -0
- package/dist/{chunk-RBKBNQGB.js → chunk-QQBBYQYD.js} +7 -6
- package/dist/{chunk-YI2HUFE3.js → chunk-RQPHPAT2.js} +1 -1
- package/dist/chunk-VAOW4WXQ.js +15 -0
- package/dist/{chunk-XYFZ57AS.js → chunk-YUUUCDXN.js} +59 -5
- package/dist/{chunk-HLKAQLTV.js → chunk-ZKGMLLUQ.js} +1 -1
- package/dist/components/brandguide/Colors.d.ts +2 -2
- package/dist/components/brandguide/Colors.d.ts.map +1 -1
- package/dist/components/brandguide/ColorsEditable.d.ts +2 -2
- package/dist/components/brandguide/ColorsEditable.d.ts.map +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/SettingsForm.d.ts.map +1 -1
- package/dist/components/editor/index.js +4 -3
- package/dist/components/primitives/ResolvedMedia.d.ts.map +1 -1
- package/dist/components/primitives/index.js +2 -2
- package/dist/components/sections/Container/Container.d.ts +2 -2
- package/dist/components/sections/Container/Container.d.ts.map +1 -1
- package/dist/components/sections/Video/VideoBlock.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +2 -2
- package/dist/components/sections/register-schemas.js +5 -5
- package/dist/components/sections/register.js +5 -5
- package/dist/components/shared/HistoryChangesModal.d.ts +39 -0
- package/dist/components/shared/HistoryChangesModal.d.ts.map +1 -0
- package/dist/components/shared/HistoryPopover.d.ts +3 -1
- package/dist/components/shared/HistoryPopover.d.ts.map +1 -1
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shared/Popover.d.ts +10 -1
- package/dist/components/shared/Popover.d.ts.map +1 -1
- package/dist/components/shell/EditorModal.d.ts +6 -1
- package/dist/components/shell/EditorModal.d.ts.map +1 -1
- package/dist/components/shell/EditorModalContext.d.ts +3 -2
- package/dist/components/shell/EditorModalContext.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/hooks/useEditorPublish.d.ts +5 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.js +9 -12
- package/dist/lib/events.d.ts +18 -0
- package/dist/lib/events.d.ts.map +1 -1
- package/dist/lib/history-buckets.d.ts +30 -0
- package/dist/lib/history-buckets.d.ts.map +1 -0
- package/dist/lib/index.d.ts +1 -1
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +4 -2
- package/dist/lib/media-folders.d.ts +13 -0
- package/dist/lib/media-folders.d.ts.map +1 -1
- package/dist/lib/media-folders.js +172 -0
- package/dist/lib/registry.d.ts +7 -1
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/lib/theme-vars.d.ts +19 -4
- package/dist/lib/theme-vars.d.ts.map +1 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +9 -0
- package/dist/storage/types.d.ts +14 -0
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +9 -1
- package/src/components/brandguide/Colors.tsx +2 -1
- package/src/components/brandguide/ColorsEditable.tsx +2 -1
- package/src/components/editor/SectionWrapper.tsx +1 -0
- package/src/components/editor/SettingsForm.tsx +5 -2
- package/src/components/primitives/ResolvedMedia.tsx +6 -0
- package/src/components/sections/Container/Container.tsx +5 -4
- package/src/components/sections/Video/VideoBlock.tsx +9 -1
- package/src/components/shared/HistoryChangesModal.tsx +256 -0
- package/src/components/shared/HistoryPopover.tsx +255 -76
- package/src/components/shared/Navigation.tsx +30 -9
- package/src/components/shared/Popover.tsx +95 -2
- package/src/components/shell/EditorModal.tsx +26 -4
- package/src/components/shell/EditorModalContext.tsx +5 -4
- package/src/components/shell/EditorShell.tsx +51 -2
- package/src/components/shell/SiteSettingsModal.tsx +1 -1
- package/src/hooks/useEditorPublish.ts +15 -6
- package/src/hooks/useMediaPipeline.ts +4 -0
- package/src/lib/events.ts +11 -0
- package/src/lib/history-buckets.ts +190 -0
- package/src/lib/index.ts +1 -1
- package/src/lib/media-folders.ts +16 -0
- package/src/lib/registry.ts +9 -1
- package/src/lib/theme-vars.ts +25 -4
- package/src/storage/index.ts +1 -1
- package/src/storage/types.ts +15 -0
|
@@ -37,7 +37,8 @@ import { SectionTypePicker } from "./SectionTypePicker";
|
|
|
37
37
|
import { ensureSectionsRegistered } from "../sections/register";
|
|
38
38
|
import { getSection, getAllSections, getSchema } from "../../lib/registry";
|
|
39
39
|
import { cornerRadiusValue } from "../../lib/corner-radius";
|
|
40
|
-
import { primaryVarValue, accentVarValue } from "../../lib/theme-vars";
|
|
40
|
+
import { primaryVarValue, accentVarValue, onPrimaryVarValue } from "../../lib/theme-vars";
|
|
41
|
+
import { rebaseMediaManifest } from "../../lib/media-folders";
|
|
41
42
|
import { childInsertableTypes, insertChildAt } from "../../lib/container-ops";
|
|
42
43
|
import { buildInsertMenuOptions, resolveInsertedContent } from "../../lib/insert-menu";
|
|
43
44
|
|
|
@@ -88,7 +89,7 @@ import { useBuildStatus } from "../../hooks/useBuildStatus";
|
|
|
88
89
|
import { useMediaPipeline } from "../../hooks/useMediaPipeline";
|
|
89
90
|
import { formatTimestamp } from "../../lib/timestamp";
|
|
90
91
|
import { generateSiteNav } from "../../lib/nav";
|
|
91
|
-
import { siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent } from "../../lib/events";
|
|
92
|
+
import { siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent, historyRestoreEvent, historyRestoreFailedEvent, editorDirtyEvent } from "../../lib/events";
|
|
92
93
|
import {
|
|
93
94
|
homePage, pageById, pageBySlug, pageDisplayTitle, addSectionToPage, removeSectionFromPages,
|
|
94
95
|
reorderSectionInPage, audiencePasses,
|
|
@@ -260,6 +261,10 @@ export default function EditorShell({
|
|
|
260
261
|
// writes onto the latest config instead of a stale closure.
|
|
261
262
|
const siteConfigRef = useRef<SiteConfig | null>(siteConfig);
|
|
262
263
|
useEffect(() => { siteConfigRef.current = siteConfig; }, [siteConfig]);
|
|
264
|
+
// Mirrors mediaManifest so recoverConflict can rebase it synchronously and the
|
|
265
|
+
// immediate 409 retry assembles the saved manifest against the rebased copy.
|
|
266
|
+
const mediaManifestRef = useRef<MediaManifest>(mediaManifest);
|
|
267
|
+
useEffect(() => { mediaManifestRef.current = mediaManifest; }, [mediaManifest]);
|
|
263
268
|
// Guards the debounced preview-image composition effect (below): skips
|
|
264
269
|
// recomposing on the very first run when a preview already exists, and
|
|
265
270
|
// dedupes concurrent runs against the same composition signature.
|
|
@@ -487,6 +492,11 @@ export default function EditorShell({
|
|
|
487
492
|
if (applied.droppedDirtyIds.length > 0) await deleteSectionRows(applied.droppedDirtyIds);
|
|
488
493
|
}
|
|
489
494
|
|
|
495
|
+
const rebasedManifest = remote.mediaManifest
|
|
496
|
+
? rebaseMediaManifest(mediaManifestRef.current, remote.mediaManifest)
|
|
497
|
+
: null;
|
|
498
|
+
if (rebasedManifest) setMediaManifest(rebasedManifest);
|
|
499
|
+
|
|
490
500
|
setSections(mergedSections);
|
|
491
501
|
setSiteIndex(mergedIndex);
|
|
492
502
|
setSavedSha(remote.savedBranchSha);
|
|
@@ -497,6 +507,7 @@ export default function EditorShell({
|
|
|
497
507
|
// (and onBaselineCommitted reads the diff refs right after it), before
|
|
498
508
|
// React flushes the setState calls above into their mirror effects.
|
|
499
509
|
savedShaRef.current = remote.savedBranchSha;
|
|
510
|
+
if (rebasedManifest) mediaManifestRef.current = rebasedManifest;
|
|
500
511
|
sectionsRef.current = mergedSections;
|
|
501
512
|
siteIndexRef.current = mergedIndex;
|
|
502
513
|
changedSectionIdsRef.current = new Set(remote.changedSectionIds);
|
|
@@ -581,6 +592,11 @@ export default function EditorShell({
|
|
|
581
592
|
});
|
|
582
593
|
|
|
583
594
|
// The rebased result goes into React state (live view)...
|
|
595
|
+
if (remote.mediaManifest) {
|
|
596
|
+
const rebasedManifest = rebaseMediaManifest(mediaManifestRef.current, remote.mediaManifest);
|
|
597
|
+
setMediaManifest(rebasedManifest);
|
|
598
|
+
mediaManifestRef.current = rebasedManifest;
|
|
599
|
+
}
|
|
584
600
|
setSections(result.sections);
|
|
585
601
|
setSiteIndex(result.index);
|
|
586
602
|
setSavedSha(remote.savedBranchSha);
|
|
@@ -778,6 +794,7 @@ export default function EditorShell({
|
|
|
778
794
|
focusedSectionIdRef.current = null;
|
|
779
795
|
},
|
|
780
796
|
mediaManifest,
|
|
797
|
+
mediaManifestRef,
|
|
781
798
|
manifestDirty: mediaPipeline.manifestDirty,
|
|
782
799
|
clearManifestDirty: () => mediaPipeline.setManifestDirty(false),
|
|
783
800
|
pendingMediaItems: mediaPipeline.pendingMediaItems,
|
|
@@ -890,6 +907,7 @@ export default function EditorShell({
|
|
|
890
907
|
const accentVar = accentVarValue(config);
|
|
891
908
|
root.style.setProperty("--color-primary", primaryVar);
|
|
892
909
|
root.style.setProperty("--color-primary-contrast", accentVar);
|
|
910
|
+
root.style.setProperty("--color-on-primary", onPrimaryVarValue(config));
|
|
893
911
|
root.style.setProperty("--font-heading", `${config.headingFont}, system-ui, sans-serif`);
|
|
894
912
|
root.style.setProperty("--font-body", `${config.bodyFont}, system-ui, sans-serif`);
|
|
895
913
|
root.style.setProperty("--heading-text-transform", config.uppercaseHeadings ? "uppercase" : "none");
|
|
@@ -1456,12 +1474,42 @@ export default function EditorShell({
|
|
|
1456
1474
|
if (!response.ok) throw new Error(`Restore failed: ${response.status}`);
|
|
1457
1475
|
await discardLocalChanges();
|
|
1458
1476
|
window.location.reload();
|
|
1477
|
+
return true;
|
|
1459
1478
|
} catch (err) {
|
|
1460
1479
|
console.error("Failed to restore:", err);
|
|
1461
1480
|
setIsRestoring(false);
|
|
1481
|
+
return false;
|
|
1462
1482
|
}
|
|
1463
1483
|
}, []);
|
|
1464
1484
|
|
|
1485
|
+
// Restore requested straight from the history navigator. The navigator has
|
|
1486
|
+
// already confirmed with the user, so this loads the content at that sha and
|
|
1487
|
+
// runs the same restore path the HistoryToolbar uses.
|
|
1488
|
+
useEffect(() => {
|
|
1489
|
+
return historyRestoreEvent.listen(async ({ sha }) => {
|
|
1490
|
+
try {
|
|
1491
|
+
const res = await fetch(`/api/content/history?sha=${sha}`);
|
|
1492
|
+
if (!res.ok) throw new Error(`Failed to load version: ${res.status}`);
|
|
1493
|
+
const data = await res.json();
|
|
1494
|
+
const ok = await handleHistoryRestore({
|
|
1495
|
+
sections: data.sections,
|
|
1496
|
+
index: data.index,
|
|
1497
|
+
siteConfig: data.siteConfig,
|
|
1498
|
+
});
|
|
1499
|
+
// On success the page reloads, so only a failure ever reaches here.
|
|
1500
|
+
if (!ok) historyRestoreFailedEvent.dispatch({ message: "Couldn't publish the restore." });
|
|
1501
|
+
} catch (err) {
|
|
1502
|
+
console.error("Failed to restore from history:", err);
|
|
1503
|
+
historyRestoreFailedEvent.dispatch({ message: "Couldn't load that version." });
|
|
1504
|
+
}
|
|
1505
|
+
});
|
|
1506
|
+
}, [handleHistoryRestore]);
|
|
1507
|
+
|
|
1508
|
+
// Publish unsaved-draft state to the other islands (see editorDirtyEvent).
|
|
1509
|
+
useEffect(() => {
|
|
1510
|
+
editorDirtyEvent.dispatch({ hasUnsavedChanges: dirtySectionIds.size > 0 });
|
|
1511
|
+
}, [dirtySectionIds]);
|
|
1512
|
+
|
|
1465
1513
|
// --- Render ---
|
|
1466
1514
|
|
|
1467
1515
|
if (shellState.phase === "loading-content") {
|
|
@@ -2269,6 +2317,7 @@ function GlobalModal() {
|
|
|
2269
2317
|
isOpen={modalState !== null}
|
|
2270
2318
|
onClose={closeModal}
|
|
2271
2319
|
title={modalState?.title ?? ""}
|
|
2320
|
+
tabbed={modalState?.tabbed}
|
|
2272
2321
|
>
|
|
2273
2322
|
{modalState?.content}
|
|
2274
2323
|
</EditorModal>
|
|
@@ -106,7 +106,7 @@ export function SiteSettingsModal({ isOpen, onClose, siteConfig, onSiteConfigCha
|
|
|
106
106
|
const tabItems = allTabs.filter((t) => t.show).map(({ id, label, content }) => ({ id, label, content }));
|
|
107
107
|
|
|
108
108
|
return (
|
|
109
|
-
<EditorModal isOpen={isOpen} onClose={onClose} title="Site Settings" size="settings" noPadding>
|
|
109
|
+
<EditorModal isOpen={isOpen} onClose={onClose} title="Site Settings" size="settings" noPadding tabbed>
|
|
110
110
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
111
111
|
<Tabs tabs={tabItems} />
|
|
112
112
|
|
|
@@ -70,6 +70,10 @@ interface PublishDeps {
|
|
|
70
70
|
* clears only those (a deletion scheduled mid-save must stay scheduled). */
|
|
71
71
|
onSuccess: (savedSectionIds: string[], meta: { editToken: number; sentDeletedIds: string[] }) => void;
|
|
72
72
|
mediaManifest: MediaManifest;
|
|
73
|
+
/** Optional ref mirror of `mediaManifest`, read at save time. recoverConflict
|
|
74
|
+
* refreshes it synchronously, so the immediate 409 retry assembles against
|
|
75
|
+
* the rebased manifest instead of this render's pre-conflict copy. */
|
|
76
|
+
mediaManifestRef?: React.RefObject<MediaManifest>;
|
|
73
77
|
manifestDirty: boolean;
|
|
74
78
|
clearManifestDirty: () => void;
|
|
75
79
|
pendingMediaItems: MediaItem[];
|
|
@@ -126,6 +130,7 @@ export function useEditorPublish({
|
|
|
126
130
|
deletedSectionIds,
|
|
127
131
|
onSuccess,
|
|
128
132
|
mediaManifest,
|
|
133
|
+
mediaManifestRef,
|
|
129
134
|
manifestDirty,
|
|
130
135
|
clearManifestDirty,
|
|
131
136
|
pendingMediaItems,
|
|
@@ -201,11 +206,12 @@ export function useEditorPublish({
|
|
|
201
206
|
if (hasMediaChanges) {
|
|
202
207
|
// Preserves folders (and any future manifest keys) and heals dangling
|
|
203
208
|
// folder refs — never rebuild the manifest as a bare { images }.
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
+
// The assembly starts from THIS editor's view, so what we send can be
|
|
210
|
+
// missing entries another writer added since we loaded. That is no longer
|
|
211
|
+
// destructive: applyContentWrite folds this into the manifest already on
|
|
212
|
+
// the branch, so a save can only add, update, or explicitly delete. The
|
|
213
|
+
// client side of the same story is rebaseMediaManifest, which refreshes
|
|
214
|
+
// our view on 409 recovery and on a peer's saved-event.
|
|
209
215
|
updatedManifest = assembleSavedManifest(
|
|
210
216
|
manifest,
|
|
211
217
|
mediaUploads.map((u) => u.item),
|
|
@@ -390,7 +396,10 @@ export function useEditorPublish({
|
|
|
390
396
|
return performSave({
|
|
391
397
|
siteConfig: siteConfig!,
|
|
392
398
|
siteIndexRef, deletedSectionIds, isConfigDirty,
|
|
393
|
-
pendingMediaItems, pendingMediaDeletions,
|
|
399
|
+
pendingMediaItems, pendingMediaDeletions, manifestDirty,
|
|
400
|
+
// Read through the ref (when the shell supplies one) so the 409 retry
|
|
401
|
+
// below sees recoverConflict's rebased manifest, not this render's copy.
|
|
402
|
+
mediaManifest: mediaManifestRef?.current ?? mediaManifest,
|
|
394
403
|
saveChunkBudget,
|
|
395
404
|
});
|
|
396
405
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
removePendingMediaDeletion,
|
|
14
14
|
} from "../lib/dexie";
|
|
15
15
|
import { generateVideoPoster } from "../media/videoPoster";
|
|
16
|
+
import { ASSET_CACHE_CONTROL } from "../storage/types";
|
|
16
17
|
import { tallyImageIds, collectImageIds, clearImageIds, applyLogoDeletion } from "../lib/image-refs";
|
|
17
18
|
import {
|
|
18
19
|
createFolderId, uniqueFolderName, effectiveParentId, canMoveFolder,
|
|
@@ -285,6 +286,9 @@ export function useMediaPipeline({
|
|
|
285
286
|
const xhr = new XMLHttpRequest();
|
|
286
287
|
xhr.open("PUT", uploadUrl);
|
|
287
288
|
xhr.setRequestHeader("Content-Type", file.type);
|
|
289
|
+
// Without this the object is stored as `no-cache` and every later
|
|
290
|
+
// play is billed origin egress — see ASSET_CACHE_CONTROL.
|
|
291
|
+
xhr.setRequestHeader("Cache-Control", ASSET_CACHE_CONTROL);
|
|
288
292
|
xhr.upload.onprogress = (e) => {
|
|
289
293
|
if (e.lengthComputable) setUploadProgress((prev) => ({ ...prev, [hash as string]: e.loaded / e.total }));
|
|
290
294
|
};
|
package/src/lib/events.ts
CHANGED
|
@@ -23,5 +23,16 @@ export const navChangeEvent = createEvent<NavItem[]>("sitenavchange");
|
|
|
23
23
|
export const darkModeEvent = createEvent<string>("sitedarkmode");
|
|
24
24
|
export const siteNameChangeEvent = createEvent<string>("sitenamechange");
|
|
25
25
|
export const historySelectEvent = createEvent<{ sha: string; date: string }>("history-select");
|
|
26
|
+
/** Restore straight from the history navigator, skipping the browse-then-restore
|
|
27
|
+
* detour. The shell loads the content at `sha` and runs the same restore path
|
|
28
|
+
* HistoryToolbar uses. Confirmation happens before this is dispatched. */
|
|
29
|
+
export const historyRestoreEvent = createEvent<{ sha: string; date: string }>("history-restore");
|
|
30
|
+
/** Restore failed. The navigator is a separate island from the shell that does
|
|
31
|
+
* the writing, so without this the failure is invisible and the UI sits on
|
|
32
|
+
* "Restoring…" forever. */
|
|
33
|
+
export const historyRestoreFailedEvent = createEvent<{ message: string }>("history-restore-failed");
|
|
34
|
+
/** Unsaved-draft state, published by the shell for other islands (the history
|
|
35
|
+
* navigator warns before restoring, which discards drafts irrecoverably). */
|
|
36
|
+
export const editorDirtyEvent = createEvent<{ hasUnsavedChanges: boolean }>("editor-dirty");
|
|
26
37
|
export const siteNavChangeEvent = createEvent<SiteNav>("sitenavchange-v2");
|
|
27
38
|
export const pageSelectEvent = createEvent<string>("pageselect");
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
export interface HistoryCommit {
|
|
2
|
+
sha: string;
|
|
3
|
+
date: string;
|
|
4
|
+
/** First parent's sha; null for the repo's root commit. */
|
|
5
|
+
parent: string | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type NodeKind = "commit" | "day" | "week" | "month" | "year";
|
|
9
|
+
|
|
10
|
+
export interface HistoryNode {
|
|
11
|
+
/** Stable React key, unique within the tree. */
|
|
12
|
+
id: string;
|
|
13
|
+
kind: NodeKind;
|
|
14
|
+
label: string;
|
|
15
|
+
sublabel: string | null;
|
|
16
|
+
count: number;
|
|
17
|
+
/** Newest commit in this node — what "browse" opens. */
|
|
18
|
+
newestSha: string | null;
|
|
19
|
+
/** Compare base: parent of this node's oldest commit. Null at repo root. */
|
|
20
|
+
baseSha: string | null;
|
|
21
|
+
children: HistoryNode[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type GroupId =
|
|
25
|
+
| "today" | "this-week" | "previous-weeks" | "earlier-months" | "previous-years";
|
|
26
|
+
|
|
27
|
+
export interface HistoryGroup {
|
|
28
|
+
id: GroupId;
|
|
29
|
+
label: string;
|
|
30
|
+
nodes: HistoryNode[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const GROUPS: { id: GroupId; label: string }[] = [
|
|
34
|
+
{ id: "today", label: "Today" },
|
|
35
|
+
{ id: "this-week", label: "Earlier this week" },
|
|
36
|
+
{ id: "previous-weeks", label: "Previous weeks" },
|
|
37
|
+
{ id: "earlier-months", label: "Earlier months" },
|
|
38
|
+
{ id: "previous-years", label: "Previous years" },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
// Calendar-day arithmetic (not fixed-ms) so DST transitions don't shift boundaries.
|
|
42
|
+
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
43
|
+
const addDays = (d: Date, n: number) =>
|
|
44
|
+
new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
|
|
45
|
+
const startOfYear = (d: Date) => new Date(d.getFullYear(), 0, 1);
|
|
46
|
+
|
|
47
|
+
const WEEKDAY = new Intl.DateTimeFormat(undefined, { weekday: "long" });
|
|
48
|
+
const MONTH = new Intl.DateTimeFormat(undefined, { month: "long" });
|
|
49
|
+
const MON_DAY = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" });
|
|
50
|
+
const TIME = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" });
|
|
51
|
+
|
|
52
|
+
interface Dated extends HistoryCommit {
|
|
53
|
+
at: Date;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Oldest-first list → node metadata shared by every non-commit node. */
|
|
57
|
+
function rollup(sorted: Dated[]): Pick<HistoryNode, "count" | "newestSha" | "baseSha"> {
|
|
58
|
+
return {
|
|
59
|
+
count: sorted.length,
|
|
60
|
+
newestSha: sorted.length ? sorted[sorted.length - 1].sha : null,
|
|
61
|
+
baseSha: sorted.length ? sorted[0].parent : null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function commitNode(c: Dated): HistoryNode {
|
|
66
|
+
return {
|
|
67
|
+
id: `c:${c.sha}`,
|
|
68
|
+
kind: "commit",
|
|
69
|
+
label: TIME.format(c.at),
|
|
70
|
+
sublabel: null,
|
|
71
|
+
count: 1,
|
|
72
|
+
newestSha: c.sha,
|
|
73
|
+
baseSha: c.parent,
|
|
74
|
+
children: [],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Bucket by a key derived from each commit's date, newest bucket first. */
|
|
79
|
+
function bucketBy(sorted: Dated[], key: (d: Date) => string): Map<string, Dated[]> {
|
|
80
|
+
const out = new Map<string, Dated[]>();
|
|
81
|
+
for (const c of sorted) {
|
|
82
|
+
const k = key(c.at);
|
|
83
|
+
const list = out.get(k);
|
|
84
|
+
if (list) list.push(c);
|
|
85
|
+
else out.set(k, [c]);
|
|
86
|
+
}
|
|
87
|
+
// sorted is oldest-first, so reverse the insertion order for newest-first display.
|
|
88
|
+
return new Map([...out.entries()].reverse());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const dayKey = (d: Date) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
|
92
|
+
const monthKey = (d: Date) => `${d.getFullYear()}-${d.getMonth()}`;
|
|
93
|
+
const yearKey = (d: Date) => `${d.getFullYear()}`;
|
|
94
|
+
|
|
95
|
+
/** Day nodes whose children are commit nodes. `weekday` picks the label style. */
|
|
96
|
+
function dayNodes(sorted: Dated[], weekday: boolean): HistoryNode[] {
|
|
97
|
+
return [...bucketBy(sorted, dayKey).entries()].map(([k, list]) => ({
|
|
98
|
+
id: `d:${k}`,
|
|
99
|
+
kind: "day" as const,
|
|
100
|
+
label: weekday ? WEEKDAY.format(list[0].at) : MON_DAY.format(list[0].at),
|
|
101
|
+
sublabel: weekday ? MON_DAY.format(list[0].at) : null,
|
|
102
|
+
...rollup(list),
|
|
103
|
+
children: [...list].reverse().map(commitNode),
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function monthNodes(sorted: Dated[], withYear: boolean): HistoryNode[] {
|
|
108
|
+
return [...bucketBy(sorted, monthKey).entries()].map(([k, list]) => ({
|
|
109
|
+
id: `m:${k}`,
|
|
110
|
+
kind: "month" as const,
|
|
111
|
+
label: MONTH.format(list[0].at),
|
|
112
|
+
sublabel: withYear ? String(list[0].at.getFullYear()) : null,
|
|
113
|
+
...rollup(list),
|
|
114
|
+
children: dayNodes(list, false),
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function yearNodes(sorted: Dated[]): HistoryNode[] {
|
|
119
|
+
return [...bucketBy(sorted, yearKey).entries()].map(([k, list]) => ({
|
|
120
|
+
id: `y:${k}`,
|
|
121
|
+
kind: "year" as const,
|
|
122
|
+
label: k,
|
|
123
|
+
sublabel: null,
|
|
124
|
+
...rollup(list),
|
|
125
|
+
children: monthNodes(list, false),
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Up to three 7-day windows, newest first. Windows with no commits are dropped
|
|
131
|
+
* rather than rendered empty — an editor scanning history wants the edits, not
|
|
132
|
+
* a fixed-width calendar.
|
|
133
|
+
*/
|
|
134
|
+
function weekNodes(sorted: Dated[], windowStart: Date): HistoryNode[] {
|
|
135
|
+
const out: HistoryNode[] = [];
|
|
136
|
+
for (let i = 1; i <= 3; i++) {
|
|
137
|
+
const start = addDays(windowStart, -7 * i);
|
|
138
|
+
const end = addDays(windowStart, -7 * (i - 1));
|
|
139
|
+
const list = sorted.filter((c) => c.at >= start && c.at < end);
|
|
140
|
+
if (list.length === 0) continue;
|
|
141
|
+
out.push({
|
|
142
|
+
id: `w:${dayKey(start)}`,
|
|
143
|
+
kind: "week",
|
|
144
|
+
label: `${MON_DAY.format(start)} – ${MON_DAY.format(addDays(end, -1))}`,
|
|
145
|
+
sublabel: null,
|
|
146
|
+
...rollup(list),
|
|
147
|
+
children: dayNodes(list, false),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function buildHistoryTree(commits: HistoryCommit[], now: Date): HistoryGroup[] {
|
|
154
|
+
// Oldest-first throughout; each builder reverses for display.
|
|
155
|
+
const sorted: Dated[] = commits
|
|
156
|
+
.map((c) => ({ ...c, at: new Date(c.date) }))
|
|
157
|
+
.filter((c) => !Number.isNaN(c.at.getTime()))
|
|
158
|
+
.sort((a, b) => a.at.getTime() - b.at.getTime());
|
|
159
|
+
|
|
160
|
+
const t0 = startOfDay(now); // today begins
|
|
161
|
+
const t1 = addDays(t0, -6); // "earlier this week" window begins
|
|
162
|
+
const t2 = addDays(t1, -21); // "previous weeks" window begins
|
|
163
|
+
const y0 = startOfYear(now); // current year begins
|
|
164
|
+
const monthsEnd = t2; // earlier-months covers [y0, t2)
|
|
165
|
+
const yearsEnd = t2 < y0 ? t2 : y0; // previous-years covers [-inf, yearsEnd)
|
|
166
|
+
|
|
167
|
+
const inRange = (lo: Date | null, hi: Date) =>
|
|
168
|
+
sorted.filter((c) => (lo === null || c.at >= lo) && c.at < hi);
|
|
169
|
+
|
|
170
|
+
const byId: Record<GroupId, HistoryNode[]> = {
|
|
171
|
+
"today": [...sorted.filter((c) => c.at >= t0)].reverse().map(commitNode),
|
|
172
|
+
"this-week": dayNodes(inRange(t1, t0), true),
|
|
173
|
+
"previous-weeks": weekNodes(sorted, t1),
|
|
174
|
+
"earlier-months": monthsEnd > y0 ? monthNodes(inRange(y0, monthsEnd), true) : [],
|
|
175
|
+
"previous-years": yearNodes(inRange(null, yearsEnd)),
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
return GROUPS.map((g) => ({ ...g, nodes: byId[g.id] }));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Largest node count anywhere in the tree; never 0, so callers can divide by it. */
|
|
182
|
+
export function maxNodeCount(groups: HistoryGroup[]): number {
|
|
183
|
+
let max = 1;
|
|
184
|
+
const walk = (n: HistoryNode) => {
|
|
185
|
+
if (n.count > max) max = n.count;
|
|
186
|
+
n.children.forEach(walk);
|
|
187
|
+
};
|
|
188
|
+
groups.forEach((g) => g.nodes.forEach(walk));
|
|
189
|
+
return max;
|
|
190
|
+
}
|
package/src/lib/index.ts
CHANGED
|
@@ -12,7 +12,7 @@ export {
|
|
|
12
12
|
suggestAccessibleOn,
|
|
13
13
|
type WcagLevel,
|
|
14
14
|
} from "./contrast";
|
|
15
|
-
export { primaryVarValue, accentVarValue } from "./theme-vars";
|
|
15
|
+
export { primaryVarValue, accentVarValue, onPrimaryVarValue } from "./theme-vars";
|
|
16
16
|
export { sanitizeHtml, ensureSanitizer } from "./sanitize";
|
|
17
17
|
export { gridColsClass } from "./grid";
|
|
18
18
|
export { getIcon, curatedIcons, type IconEntry } from "./icons";
|
package/src/lib/media-folders.ts
CHANGED
|
@@ -178,6 +178,22 @@ export function healMediaManifest(manifest: MediaManifest): MediaManifest {
|
|
|
178
178
|
return { ...manifest, images, folders };
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Rebase a local manifest onto a freshly-fetched remote one, so media another
|
|
183
|
+
* writer added is in our copy before the next save assembles from it. Without
|
|
184
|
+
* this a rebased session still writes its pre-conflict manifest, which silently
|
|
185
|
+
* drops their entries and orphans the files they committed.
|
|
186
|
+
*
|
|
187
|
+
* Images union, ours winning, so local alt/folder-move edits survive; a pending
|
|
188
|
+
* local deletion isn't removed from the manifest until save, so the union can't
|
|
189
|
+
* resurrect one. Folders stay ours verbatim — deleting a folder removes it from
|
|
190
|
+
* our map with no wire signal, so unioning would undo the delete. Mirrors the
|
|
191
|
+
* server-side merge in core's content-ops.
|
|
192
|
+
*/
|
|
193
|
+
export function rebaseMediaManifest(local: MediaManifest, remote: MediaManifest): MediaManifest {
|
|
194
|
+
return { ...local, images: { ...remote.images, ...local.images } };
|
|
195
|
+
}
|
|
196
|
+
|
|
181
197
|
/**
|
|
182
198
|
* Save-time manifest assembly: merge uploaded pending items, apply deletions,
|
|
183
199
|
* preserve every other manifest key (folders!), then heal.
|
package/src/lib/registry.ts
CHANGED
|
@@ -77,12 +77,20 @@ export type Thumbnail =
|
|
|
77
77
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
78
78
|
export type CustomSettingsFormProps = Record<string, any>;
|
|
79
79
|
|
|
80
|
+
export interface ModalOptions {
|
|
81
|
+
/** Content is a tab set — lock the modal height so tab switches don't resize it. */
|
|
82
|
+
tabbed?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Signature of the editor's modal opener as threaded down to sections. */
|
|
86
|
+
export type OpenModal = (title: string, content: ReactNode, options?: ModalOptions) => void;
|
|
87
|
+
|
|
80
88
|
export interface SectionProps<T = unknown> {
|
|
81
89
|
content: T;
|
|
82
90
|
options?: Record<string, unknown>;
|
|
83
91
|
onChange?: (content: T) => void;
|
|
84
92
|
isEditMode: boolean;
|
|
85
|
-
openModal?:
|
|
93
|
+
openModal?: OpenModal;
|
|
86
94
|
/** Delete this whole section. Used e.g. when a colors section's last swatch
|
|
87
95
|
* is dragged into another section, leaving it empty. */
|
|
88
96
|
onDelete?: () => void;
|
package/src/lib/theme-vars.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { SiteConfig } from "../schemas/site-config";
|
|
2
|
+
import { suggestAccessibleOn } from "./contrast";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Computes the CSS value for the brand `--color-primary` / `--color-primary-contrast`
|
|
5
|
-
* custom properties. When darkMode is "optional" AND a dark
|
|
6
|
-
* value is a CSS `light-dark(<light>, <dark>)` expression — the
|
|
7
|
-
* it per the element's `color-scheme` (the `.dark` class sets
|
|
8
|
-
* `:root` sets `color-scheme: light`). Otherwise it's the
|
|
6
|
+
* / `--color-on-primary` custom properties. When darkMode is "optional" AND a dark
|
|
7
|
+
* variant is set, the value is a CSS `light-dark(<light>, <dark>)` expression — the
|
|
8
|
+
* browser resolves it per the element's `color-scheme` (the `.dark` class sets
|
|
9
|
+
* `color-scheme: dark`; `:root` sets `color-scheme: light`). Otherwise it's the
|
|
10
|
+
* plain light hex.
|
|
9
11
|
*
|
|
10
12
|
* Single source of truth for every injection site: Layout.astro / LoginLayout.astro
|
|
11
13
|
* (SSR inline `<html style>`) and EditorShell (live setProperty + localStorage).
|
|
@@ -29,3 +31,22 @@ export function primaryVarValue(c?: Partial<ThemeColorConfig> | null): string {
|
|
|
29
31
|
export function accentVarValue(c?: Partial<ThemeColorConfig> | null): string {
|
|
30
32
|
return lightDark(c?.primaryContrast ?? DEFAULT_CONTRAST, c?.primaryContrastDark, c?.darkMode === "optional");
|
|
31
33
|
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Foreground ink for text drawn ON a `--color-primary`-filled surface (branded
|
|
37
|
+
* plates, primary cards). Derived from `primaryColor` with the same
|
|
38
|
+
* black/white WCAG pick the editor's accent "Suggest" button uses, so a
|
|
39
|
+
* primary-filled surface is legible on every brand without configuration.
|
|
40
|
+
*
|
|
41
|
+
* This exists because `--color-primary-contrast` cannot serve the role: it is
|
|
42
|
+
* the *accent* — brands legitimately repoint it at a decorative colour (Siete's
|
|
43
|
+
* gold on aubergine) that reads as a highlight but cannot carry body copy.
|
|
44
|
+
* Sections should consume `var(--color-on-primary)` for text on primary and
|
|
45
|
+
* keep `--color-primary-contrast` for accents, and derive softer inks by mixing
|
|
46
|
+
* the two brand tokens: `color-mix(in srgb, var(--color-on-primary) 70%, var(--color-primary))`.
|
|
47
|
+
*/
|
|
48
|
+
export function onPrimaryVarValue(c?: Partial<ThemeColorConfig> | null): string {
|
|
49
|
+
const light = suggestAccessibleOn(c?.primaryColor ?? DEFAULT_PRIMARY);
|
|
50
|
+
const dark = c?.primaryColorDark ? suggestAccessibleOn(c.primaryColorDark) : null;
|
|
51
|
+
return lightDark(light, dark, c?.darkMode === "optional");
|
|
52
|
+
}
|
package/src/storage/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export type { StorageProvider, FileWrite, AssetStore } from "./types";
|
|
2
|
-
export { StorageConflictError } from "./types";
|
|
2
|
+
export { StorageConflictError, ASSET_CACHE_CONTROL } from "./types";
|
package/src/storage/types.ts
CHANGED
|
@@ -39,6 +39,21 @@ export type FileWrite = {
|
|
|
39
39
|
encoding?: "utf-8" | "base64";
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* `Cache-Control` every bucket upload must set. Object paths are content-hashed
|
|
44
|
+
* ("media/{hash}/original.mp4"), so a given URL's bytes never change — replacing
|
|
45
|
+
* a video mints a new hash and therefore a new URL. That makes `immutable` safe
|
|
46
|
+
* and a one-year max-age correct.
|
|
47
|
+
*
|
|
48
|
+
* This is NOT cosmetic. A bucket upload sent with no `cache-control` request
|
|
49
|
+
* header is stored as `no-cache`, which means neither the browser nor a CDN will
|
|
50
|
+
* ever reuse a response — every play of every video becomes origin egress,
|
|
51
|
+
* including a plain page reload. That default is what exhausted the Supabase
|
|
52
|
+
* project's egress quota while the bucket sat at 46% of its size limit. Any new
|
|
53
|
+
* upload path MUST send this header.
|
|
54
|
+
*/
|
|
55
|
+
export const ASSET_CACHE_CONTROL = "max-age=31536000, immutable";
|
|
56
|
+
|
|
42
57
|
/**
|
|
43
58
|
* Optional large-asset store (Supabase Storage bucket). Holds video originals
|
|
44
59
|
* too big for the git path; deliberately generic so future large kinds (zips)
|