@morphika/andami 0.1.2 → 0.1.5
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/app/(site)/[slug]/page.tsx +2 -2
- package/app/(site)/layout.tsx +1 -0
- package/app/(site)/page.tsx +2 -2
- package/app/(site)/preview/page.tsx +4 -4
- package/app/(site)/work/[slug]/page.tsx +2 -2
- package/app/admin/layout.tsx +2 -2
- package/app/admin/login/page.tsx +5 -5
- package/app/admin/navigation/page.tsx +255 -157
- package/app/api/admin/assets/relink/confirm/route.ts +1 -1
- package/app/api/admin/pages/[slug]/route.ts +1 -1
- package/app/api/admin/settings/route.ts +40 -15
- package/app/api/admin/setup/complete/route.ts +1 -1
- package/app/api/admin/setup/route.ts +6 -3
- package/components/admin/index.ts +7 -0
- package/components/admin/nav-builder/NavGeneralSettings.tsx +11 -15
- package/components/admin/nav-builder/NavItemSettings.tsx +29 -5
- package/components/admin/nav-builder/NavLivePreview.tsx +4 -1
- package/components/admin/nav-builder/NavMobileLivePreview.tsx +226 -0
- package/components/admin/nav-builder/NavMobileSettings.tsx +223 -0
- package/components/admin/nav-builder/index.ts +2 -0
- package/components/blocks/BlockRenderer.tsx +65 -13
- package/components/blocks/ButtonBlockRenderer.tsx +29 -6
- package/components/blocks/CoverBlockRenderer.tsx +36 -14
- package/components/blocks/ImageBlockRenderer.tsx +5 -3
- package/components/blocks/ImageGridBlockRenderer.tsx +13 -6
- package/components/blocks/PageRenderer.tsx +4 -2
- package/components/blocks/ProjectGridBlockRenderer.tsx +18 -3
- package/components/blocks/SectionRenderer.tsx +9 -8
- package/components/blocks/SectionV2Renderer.tsx +8 -8
- package/components/blocks/SpacerBlockRenderer.tsx +4 -2
- package/components/blocks/TextBlockRenderer.tsx +9 -4
- package/components/builder/BuilderCanvas.tsx +10 -4
- package/components/builder/ColorPicker.tsx +51 -243
- package/components/builder/ColorSwatchPicker.tsx +214 -274
- package/components/builder/DndWrapper.tsx +5 -2
- package/components/builder/SectionV2Canvas.tsx +15 -4
- package/components/builder/asset-browser/useAssetBrowser.ts +9 -1
- package/components/builder/color-picker/AlphaSlider.tsx +141 -0
- package/components/builder/color-picker/AngleControl.tsx +138 -0
- package/components/builder/color-picker/ColorInputs.tsx +105 -0
- package/components/builder/color-picker/EyedropperButton.tsx +74 -0
- package/components/builder/color-picker/GradientBar.tsx +222 -0
- package/components/builder/color-picker/GradientPreview.tsx +53 -0
- package/components/builder/color-picker/HueSlider.tsx +124 -0
- package/components/builder/color-picker/MeshCanvas.tsx +172 -0
- package/components/builder/color-picker/MeshPointEditor.tsx +133 -0
- package/components/builder/color-picker/MeshPointList.tsx +200 -0
- package/components/builder/color-picker/PositionControl.tsx +158 -0
- package/components/builder/color-picker/SaturationCanvas.tsx +142 -0
- package/components/builder/color-picker/StopEditor.tsx +178 -0
- package/components/builder/color-picker/SwatchBar.tsx +93 -0
- package/components/builder/color-picker/UnifiedColorPicker.tsx +713 -0
- package/components/builder/color-picker/index.ts +62 -0
- package/components/builder/color-picker/types.ts +115 -0
- package/components/builder/color-picker/utils.ts +138 -0
- package/components/builder/editors/CoverBlockEditor.tsx +86 -32
- package/components/builder/editors/ProjectGridEditor.tsx +51 -4
- package/components/builder/hooks/useColumnDrag.ts +25 -27
- package/components/builder/settings-panel/BlockLayoutTab.tsx +29 -7
- package/components/builder/settings-panel/LayoutTab.tsx +382 -310
- package/components/builder/settings-panel/PageSettings.tsx +6 -4
- package/components/builder/settings-panel/ParallaxSlideSettings.tsx +2 -2
- package/components/builder/settings-panel/SectionV2LayoutTab.tsx +392 -312
- package/components/builder/settings-panel/SectionV2Settings.tsx +65 -35
- package/components/ui/Navbar.tsx +95 -25
- package/components/ui/PortfolioTracker.tsx +3 -3
- package/lib/assets.ts +1 -1
- package/lib/auth.ts +1 -1
- package/lib/builder/gradient-presets.ts +128 -0
- package/lib/builder/layout-styles.ts +16 -10
- package/lib/builder/serializer.ts +1 -0
- package/lib/builder/store-blocks.ts +48 -61
- package/lib/builder/store-helpers.ts +31 -14
- package/lib/builder/store.ts +59 -41
- package/lib/builder/types.ts +14 -0
- package/lib/color-utils.ts +200 -0
- package/lib/config/index.ts +14 -43
- package/lib/revalidate.ts +2 -2
- package/lib/sanity/queries.ts +4 -3
- package/lib/sanity/types.ts +76 -1
- package/lib/setup/detect.ts +1 -1
- package/package.json +8 -12
- package/sanity/schemas/siteSettings.ts +34 -0
- package/styles/base.css +7 -51
- package/app/globals.css +0 -7
|
@@ -10,43 +10,54 @@ type StoreSet = (
|
|
|
10
10
|
) => void;
|
|
11
11
|
type StoreGet = () => BuilderStore;
|
|
12
12
|
|
|
13
|
+
// RC-005 fix: Shared helper for updating a block's fields across all section
|
|
14
|
+
// types (V1, V2, ParallaxGroup). Eliminates duplicated row/column mapping
|
|
15
|
+
// logic between updateBlock and updateBlockDebounced.
|
|
16
|
+
function applyBlockUpdate(
|
|
17
|
+
rows: ContentItem[],
|
|
18
|
+
blockKey: string,
|
|
19
|
+
updates: Partial<ContentBlock>
|
|
20
|
+
): ContentItem[] {
|
|
21
|
+
const updateColumns = (cols: import("../../lib/sanity/types").SectionColumn[]) =>
|
|
22
|
+
cols.map((col) => ({
|
|
23
|
+
...col,
|
|
24
|
+
blocks: col.blocks.map((b) =>
|
|
25
|
+
b._key === blockKey ? ({ ...b, ...updates } as ContentBlock) : b
|
|
26
|
+
),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
return rows.map((item) => {
|
|
30
|
+
if (isPageSection(item)) {
|
|
31
|
+
const block = item.block[0];
|
|
32
|
+
if (block && block._key === blockKey) {
|
|
33
|
+
return { ...item, block: [{ ...block, ...updates } as SectionBlock] };
|
|
34
|
+
}
|
|
35
|
+
return item;
|
|
36
|
+
}
|
|
37
|
+
if (isPageSectionV2(item)) {
|
|
38
|
+
return { ...item, columns: updateColumns(item.columns) };
|
|
39
|
+
}
|
|
40
|
+
if (isParallaxGroup(item)) {
|
|
41
|
+
return {
|
|
42
|
+
...item,
|
|
43
|
+
slides: (item as ParallaxGroup).slides.map((slide) => ({
|
|
44
|
+
...slide,
|
|
45
|
+
columns: updateColumns(slide.columns),
|
|
46
|
+
})),
|
|
47
|
+
} as ContentItem;
|
|
48
|
+
}
|
|
49
|
+
return item;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
13
53
|
export function createBlockActions(set: StoreSet, get: StoreGet) {
|
|
14
54
|
return {
|
|
15
55
|
// ---- Block operations ----
|
|
16
56
|
|
|
17
57
|
updateBlock: (blockKey: string, updates: Partial<ContentBlock>): void => {
|
|
18
58
|
// Snapshot is NOT pushed here to avoid polluting undo history on every keystroke.
|
|
19
|
-
const updateColumns = (cols: import("../../lib/sanity/types").SectionColumn[]) =>
|
|
20
|
-
cols.map((col) => ({
|
|
21
|
-
...col,
|
|
22
|
-
blocks: col.blocks.map((b) =>
|
|
23
|
-
b._key === blockKey ? ({ ...b, ...updates } as ContentBlock) : b
|
|
24
|
-
),
|
|
25
|
-
}));
|
|
26
|
-
|
|
27
59
|
set((state) => ({
|
|
28
|
-
rows: state.rows
|
|
29
|
-
if (isPageSection(item)) {
|
|
30
|
-
const block = item.block[0];
|
|
31
|
-
if (block && block._key === blockKey) {
|
|
32
|
-
return { ...item, block: [{ ...block, ...updates } as SectionBlock] };
|
|
33
|
-
}
|
|
34
|
-
return item;
|
|
35
|
-
}
|
|
36
|
-
if (isPageSectionV2(item)) {
|
|
37
|
-
return { ...item, columns: updateColumns(item.columns) };
|
|
38
|
-
}
|
|
39
|
-
if (isParallaxGroup(item)) {
|
|
40
|
-
return {
|
|
41
|
-
...item,
|
|
42
|
-
slides: (item as ParallaxGroup).slides.map((slide) => ({
|
|
43
|
-
...slide,
|
|
44
|
-
columns: updateColumns(slide.columns),
|
|
45
|
-
})),
|
|
46
|
-
} as ContentItem;
|
|
47
|
-
}
|
|
48
|
-
return item;
|
|
49
|
-
}),
|
|
60
|
+
rows: applyBlockUpdate(state.rows, blockKey, updates),
|
|
50
61
|
isDirty: true,
|
|
51
62
|
}));
|
|
52
63
|
},
|
|
@@ -126,7 +137,11 @@ export function createBlockActions(set: StoreSet, get: StoreGet) {
|
|
|
126
137
|
},
|
|
127
138
|
|
|
128
139
|
moveBlock: (blockKey: string, targetSectionKey: string, targetColumnKey: string, toIndex: number): void => {
|
|
129
|
-
|
|
140
|
+
// RC-001 fix: Validate first without side effects, then push snapshot
|
|
141
|
+
// BEFORE applying mutation. This ensures undo state captures the
|
|
142
|
+
// pre-mutation rows, even if another mutation races in between.
|
|
143
|
+
const currentRows = get().rows;
|
|
144
|
+
const result = moveBlockInState(currentRows, blockKey, targetSectionKey, targetColumnKey, toIndex);
|
|
130
145
|
if (!result) return;
|
|
131
146
|
|
|
132
147
|
get()._pushSnapshot();
|
|
@@ -153,39 +168,11 @@ export function createBlockActions(set: StoreSet, get: StoreGet) {
|
|
|
153
168
|
},
|
|
154
169
|
|
|
155
170
|
// ---- Debounced block update (no undo snapshot per keystroke) ----
|
|
171
|
+
// RC-005 fix: Now delegates to shared applyBlockUpdate helper.
|
|
156
172
|
|
|
157
173
|
updateBlockDebounced: (blockKey: string, updates: Partial<ContentBlock>): void => {
|
|
158
|
-
const updateColumns = (cols: import("../../lib/sanity/types").SectionColumn[]) =>
|
|
159
|
-
cols.map((col) => ({
|
|
160
|
-
...col,
|
|
161
|
-
blocks: col.blocks.map((b) =>
|
|
162
|
-
b._key === blockKey ? ({ ...b, ...updates } as ContentBlock) : b
|
|
163
|
-
),
|
|
164
|
-
}));
|
|
165
|
-
|
|
166
174
|
set((state) => ({
|
|
167
|
-
rows: state.rows
|
|
168
|
-
if (isPageSection(item)) {
|
|
169
|
-
const block = item.block[0];
|
|
170
|
-
if (block && block._key === blockKey) {
|
|
171
|
-
return { ...item, block: [{ ...block, ...updates } as SectionBlock] };
|
|
172
|
-
}
|
|
173
|
-
return item;
|
|
174
|
-
}
|
|
175
|
-
if (isPageSectionV2(item)) {
|
|
176
|
-
return { ...item, columns: updateColumns(item.columns) };
|
|
177
|
-
}
|
|
178
|
-
if (isParallaxGroup(item)) {
|
|
179
|
-
return {
|
|
180
|
-
...item,
|
|
181
|
-
slides: (item as ParallaxGroup).slides.map((slide) => ({
|
|
182
|
-
...slide,
|
|
183
|
-
columns: updateColumns(slide.columns),
|
|
184
|
-
})),
|
|
185
|
-
} as ContentItem;
|
|
186
|
-
}
|
|
187
|
-
return item;
|
|
188
|
-
}),
|
|
175
|
+
rows: applyBlockUpdate(state.rows, blockKey, updates),
|
|
189
176
|
isDirty: true,
|
|
190
177
|
}));
|
|
191
178
|
},
|
|
@@ -60,23 +60,40 @@ export function moveBlockInState(
|
|
|
60
60
|
return { ...c, blocks };
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
// Pass 1: find and remove block from
|
|
64
|
-
|
|
63
|
+
// Pass 1: find and remove block from its current section (V2 + parallax slides)
|
|
64
|
+
// RC-006 fix: Early-exit once block is found, consistent with selectBlock()
|
|
65
|
+
// search pattern. Also searches PageSection V1 for completeness.
|
|
66
|
+
let rowsAfterRemove = rows;
|
|
67
|
+
for (let i = 0; i < rows.length; i++) {
|
|
68
|
+
const item = rows[i];
|
|
65
69
|
if (isPageSectionV2(item)) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
const updatedCols = removeFromColumns(item.columns);
|
|
71
|
+
if (movedBlock) {
|
|
72
|
+
rowsAfterRemove = rows.map((r, idx) =>
|
|
73
|
+
idx === i ? { ...r, columns: updatedCols } as ContentItem : r
|
|
74
|
+
);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
} else if (isParallaxGroup(item)) {
|
|
69
78
|
const group = item as ParallaxGroup;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
let foundInSlide = false;
|
|
80
|
+
const updatedSlides = group.slides.map((slide) => {
|
|
81
|
+
if (foundInSlide) return slide; // skip remaining slides after block found
|
|
82
|
+
const updatedCols = removeFromColumns(slide.columns);
|
|
83
|
+
if (movedBlock) {
|
|
84
|
+
foundInSlide = true;
|
|
85
|
+
return { ...slide, columns: updatedCols };
|
|
86
|
+
}
|
|
87
|
+
return slide;
|
|
88
|
+
});
|
|
89
|
+
if (foundInSlide) {
|
|
90
|
+
rowsAfterRemove = rows.map((r, idx) =>
|
|
91
|
+
idx === i ? { ...group, slides: updatedSlides } as ContentItem : r
|
|
92
|
+
);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
77
95
|
}
|
|
78
|
-
|
|
79
|
-
});
|
|
96
|
+
}
|
|
80
97
|
|
|
81
98
|
if (!movedBlock) return null;
|
|
82
99
|
|
package/lib/builder/store.ts
CHANGED
|
@@ -34,6 +34,47 @@ import { createSectionActions } from "./store-sections";
|
|
|
34
34
|
import { createBlockActions } from "./store-blocks";
|
|
35
35
|
import { createCanvasActions } from "./store-canvas";
|
|
36
36
|
|
|
37
|
+
// ============================================
|
|
38
|
+
// RC-003 fix: Block parent key cache — O(1) lookup in selectBlock().
|
|
39
|
+
// Invalidated when `rows` reference changes (immutable state).
|
|
40
|
+
// ============================================
|
|
41
|
+
|
|
42
|
+
interface BlockParent { rowKey: string; colKey: string | null }
|
|
43
|
+
let _blockParentCache: Map<string, BlockParent> | null = null;
|
|
44
|
+
let _blockParentCacheRowsRef: ContentItem[] | null = null;
|
|
45
|
+
|
|
46
|
+
function getBlockParentCache(rows: ContentItem[]): Map<string, BlockParent> {
|
|
47
|
+
if (_blockParentCache && _blockParentCacheRowsRef === rows) return _blockParentCache;
|
|
48
|
+
|
|
49
|
+
const cache = new Map<string, BlockParent>();
|
|
50
|
+
for (const item of rows) {
|
|
51
|
+
if (isPageSection(item)) {
|
|
52
|
+
const sBlock = Array.isArray(item.block) ? item.block[0] : undefined;
|
|
53
|
+
if (sBlock) cache.set(sBlock._key, { rowKey: item._key, colKey: null });
|
|
54
|
+
} else if (isPageSectionV2(item)) {
|
|
55
|
+
const v2 = item as PageSectionV2;
|
|
56
|
+
for (const col of v2.columns) {
|
|
57
|
+
for (const b of col.blocks) {
|
|
58
|
+
cache.set(b._key, { rowKey: item._key, colKey: col._key });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} else if (isParallaxGroup(item)) {
|
|
62
|
+
const group = item as ParallaxGroup;
|
|
63
|
+
for (const slide of group.slides) {
|
|
64
|
+
for (const col of slide.columns) {
|
|
65
|
+
for (const b of col.blocks) {
|
|
66
|
+
cache.set(b._key, { rowKey: slide._key, colKey: col._key });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_blockParentCache = cache;
|
|
74
|
+
_blockParentCacheRowsRef = rows;
|
|
75
|
+
return cache;
|
|
76
|
+
}
|
|
77
|
+
|
|
37
78
|
// ============================================
|
|
38
79
|
// Initial State
|
|
39
80
|
// ============================================
|
|
@@ -90,6 +131,9 @@ const initialState: BuilderState = {
|
|
|
90
131
|
_history: [],
|
|
91
132
|
_future: [],
|
|
92
133
|
_isTimeTraveling: false,
|
|
134
|
+
|
|
135
|
+
// Color picker live preview (Phase 4)
|
|
136
|
+
colorPickerPreview: null,
|
|
93
137
|
};
|
|
94
138
|
|
|
95
139
|
// ============================================
|
|
@@ -102,12 +146,13 @@ export const useBuilderStore = create<BuilderStore>((set, get) => ({
|
|
|
102
146
|
// ---- Internal: push snapshot before mutations ----
|
|
103
147
|
|
|
104
148
|
// BUG-010 fix: Snapshots now include pageSettings alongside rows
|
|
149
|
+
// RC-004 fix: Use functional set() for atomic read+write — prevents two
|
|
150
|
+
// rapid mutations from reading the same _history before either writes.
|
|
105
151
|
_pushSnapshot: () => {
|
|
106
|
-
|
|
107
|
-
set({
|
|
152
|
+
set((state) => ({
|
|
108
153
|
_history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
|
|
109
154
|
_future: [], // new mutation clears redo stack
|
|
110
|
-
});
|
|
155
|
+
}));
|
|
111
156
|
},
|
|
112
157
|
|
|
113
158
|
// ---- History (Undo/Redo) ----
|
|
@@ -187,47 +232,15 @@ export const useBuilderStore = create<BuilderStore>((set, get) => ({
|
|
|
187
232
|
}
|
|
188
233
|
// BUG-002 fix: resolve parent row and column keys so SettingsPanel
|
|
189
234
|
// and keyboard shortcuts always have consistent selection state.
|
|
235
|
+
// RC-003 fix: Use cached parent lookup — O(1) instead of O(n) iteration.
|
|
190
236
|
const state = get();
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const searchColumns = (columns: import("../../lib/sanity/types").SectionColumn[], rowKey: string): boolean => {
|
|
195
|
-
for (const col of columns) {
|
|
196
|
-
if (col.blocks.some((b) => b._key === key)) {
|
|
197
|
-
parentRowKey = rowKey;
|
|
198
|
-
parentColKey = col._key;
|
|
199
|
-
return true;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
return false;
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
for (const item of state.rows) {
|
|
206
|
-
if (isPageSection(item)) {
|
|
207
|
-
const sBlock = Array.isArray(item.block) ? item.block[0] : undefined;
|
|
208
|
-
if (sBlock && sBlock._key === key) {
|
|
209
|
-
parentRowKey = item._key;
|
|
210
|
-
break;
|
|
211
|
-
}
|
|
212
|
-
} else if (isPageSectionV2(item)) {
|
|
213
|
-
if (searchColumns(item.columns, item._key)) break;
|
|
214
|
-
} else if (isParallaxGroup(item)) {
|
|
215
|
-
const group = item as ParallaxGroup;
|
|
216
|
-
let found = false;
|
|
217
|
-
for (const slide of group.slides) {
|
|
218
|
-
// For parallax slides, the "row key" is the slide key (used as sectionKey)
|
|
219
|
-
if (searchColumns(slide.columns, slide._key)) {
|
|
220
|
-
found = true;
|
|
221
|
-
break;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
if (found) break;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
237
|
+
const cache = getBlockParentCache(state.rows);
|
|
238
|
+
const parent = cache.get(key);
|
|
239
|
+
|
|
227
240
|
set({
|
|
228
241
|
selectedBlockKey: key,
|
|
229
|
-
selectedRowKey:
|
|
230
|
-
selectedColumnKey:
|
|
242
|
+
selectedRowKey: parent?.rowKey ?? null,
|
|
243
|
+
selectedColumnKey: parent?.colKey ?? null,
|
|
231
244
|
selectedProjectCardKey: null,
|
|
232
245
|
});
|
|
233
246
|
},
|
|
@@ -325,6 +338,11 @@ export const useBuilderStore = create<BuilderStore>((set, get) => ({
|
|
|
325
338
|
markDirty: () => set({ isDirty: true }),
|
|
326
339
|
markClean: () => set({ isDirty: false }),
|
|
327
340
|
|
|
341
|
+
// ---- Color picker live preview (Phase 4) ----
|
|
342
|
+
|
|
343
|
+
setColorPickerPreview: (preview) => set({ colorPickerPreview: preview }),
|
|
344
|
+
clearColorPickerPreview: () => set({ colorPickerPreview: null }),
|
|
345
|
+
|
|
328
346
|
// ---- Spread in action groups ----
|
|
329
347
|
|
|
330
348
|
...createSectionActions(set, get),
|
package/lib/builder/types.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
SectionV2Preset,
|
|
17
17
|
SectionColumn,
|
|
18
18
|
PageMetadata,
|
|
19
|
+
ColorField,
|
|
19
20
|
} from "../../lib/sanity/types";
|
|
20
21
|
import { DEFAULT_BG_COLOR, DEFAULT_TEXT_COLOR, DEFAULT_GRID_WIDTH } from "./constants";
|
|
21
22
|
|
|
@@ -229,6 +230,15 @@ export interface BuilderState {
|
|
|
229
230
|
* Populated by CustomSectionInstanceCard/ReadOnlyCustomSection on fetch.
|
|
230
231
|
* Used by SortableRow to merge base settings with per-instance overrides. */
|
|
231
232
|
_customSectionCache: Record<string, import("../../lib/sanity/types").SectionV2Settings>;
|
|
233
|
+
|
|
234
|
+
/** Live preview overlay from color picker — shown on canvas without persisting to Sanity.
|
|
235
|
+
* Set while user is dragging in the color picker; cleared on close. */
|
|
236
|
+
colorPickerPreview: {
|
|
237
|
+
blockKey?: string;
|
|
238
|
+
sectionKey?: string;
|
|
239
|
+
field: string;
|
|
240
|
+
value: ColorField;
|
|
241
|
+
} | null;
|
|
232
242
|
}
|
|
233
243
|
|
|
234
244
|
export interface BuilderActions {
|
|
@@ -369,6 +379,10 @@ export interface BuilderActions {
|
|
|
369
379
|
zoomToFit: (viewportWidth: number, viewportHeight: number) => void;
|
|
370
380
|
zoomToPoint: (newZoom: number, cursorX: number, cursorY: number) => void;
|
|
371
381
|
zoomToFrame: (device: DeviceViewport, viewportWidth: number, viewportHeight: number) => void;
|
|
382
|
+
|
|
383
|
+
// Color picker live preview (Phase 4)
|
|
384
|
+
setColorPickerPreview: (preview: BuilderState["colorPickerPreview"]) => void;
|
|
385
|
+
clearColorPickerPreview: () => void;
|
|
372
386
|
}
|
|
373
387
|
|
|
374
388
|
export type BuilderStore = BuilderState & BuilderActions;
|
package/lib/color-utils.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Centralized color conversion utilities.
|
|
3
3
|
*
|
|
4
|
+
* Phase 2: Includes bridge functions for ColorField (solid + gradient).
|
|
5
|
+
* Renderers use these bridge functions instead of manipulating ColorField directly.
|
|
6
|
+
*
|
|
4
7
|
* Previously duplicated across:
|
|
5
8
|
* - components/builder/ColorPicker.tsx (hexToHSL, hsvToHex, hexToHSV)
|
|
6
9
|
* - components/blocks/BlockRenderer.tsx (hexToRgba)
|
|
@@ -8,6 +11,8 @@
|
|
|
8
11
|
* - components/admin/nav-builder/nav-builder-utils.ts (hexToRgba)
|
|
9
12
|
*/
|
|
10
13
|
|
|
14
|
+
import type { ColorField, GradientValue } from "./sanity/types";
|
|
15
|
+
|
|
11
16
|
/** Convert hex (#RRGGBB) to HSL values. */
|
|
12
17
|
export function hexToHSL(hex: string): { h: number; s: number; l: number } {
|
|
13
18
|
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
|
@@ -114,3 +119,198 @@ export function lerpHex(a: string, b: string, t: number): string {
|
|
|
114
119
|
const bl = clamp(bA + (bB - bA) * t);
|
|
115
120
|
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${bl.toString(16).padStart(2, "0")}`;
|
|
116
121
|
}
|
|
122
|
+
|
|
123
|
+
// ─── Deprecation warning flag (Phase 4) ───
|
|
124
|
+
let _gradientOpacityWarned = false;
|
|
125
|
+
|
|
126
|
+
// ─── ColorField Bridge Functions (Phase 2) ───
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Determines whether a ColorField value is a gradient (not a solid hex string).
|
|
130
|
+
*/
|
|
131
|
+
export function isGradient(value: ColorField): value is GradientValue {
|
|
132
|
+
return typeof value !== "string";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Converts a ColorField to a valid CSS string.
|
|
137
|
+
* This is the single conversion point for renderers.
|
|
138
|
+
*/
|
|
139
|
+
export function colorToCSS(value: ColorField): string {
|
|
140
|
+
if (typeof value === "string") return value;
|
|
141
|
+
|
|
142
|
+
switch (value.type) {
|
|
143
|
+
case "linear": {
|
|
144
|
+
const stops = value.stops
|
|
145
|
+
.map((s) => `${hexToRgba(s.color, s.alpha)} ${s.position}%`)
|
|
146
|
+
.join(", ");
|
|
147
|
+
return `linear-gradient(${value.angle}deg, ${stops})`;
|
|
148
|
+
}
|
|
149
|
+
case "radial": {
|
|
150
|
+
const stops = value.stops
|
|
151
|
+
.map((s) => `${hexToRgba(s.color, s.alpha)} ${s.position}%`)
|
|
152
|
+
.join(", ");
|
|
153
|
+
const shape = value.shape === "circle" ? "circle" : "ellipse";
|
|
154
|
+
return `radial-gradient(${shape} at ${value.position.x}% ${value.position.y}%, ${stops})`;
|
|
155
|
+
}
|
|
156
|
+
case "mesh": {
|
|
157
|
+
const layers = value.points
|
|
158
|
+
.map(
|
|
159
|
+
(p) =>
|
|
160
|
+
`radial-gradient(at ${p.x}% ${p.y}%, ${p.color} 0%, transparent 50%)`
|
|
161
|
+
)
|
|
162
|
+
.join(", ");
|
|
163
|
+
return `${layers}, ${value.background}`;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Extracts a representative hex color from a ColorField.
|
|
170
|
+
* For gradients, returns the first stop's color.
|
|
171
|
+
* Used for: text contrast checks, swatch preview, simple validations.
|
|
172
|
+
*/
|
|
173
|
+
export function resolveColorHex(value: ColorField): string {
|
|
174
|
+
if (typeof value === "string") return value;
|
|
175
|
+
switch (value.type) {
|
|
176
|
+
case "linear":
|
|
177
|
+
case "radial":
|
|
178
|
+
return value.stops[0]?.color ?? "#000000";
|
|
179
|
+
case "mesh":
|
|
180
|
+
return value.points[0]?.color ?? value.background;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Returns the correct CSS property object for a background color.
|
|
186
|
+
* - Solid: { backgroundColor: "#ff0000" }
|
|
187
|
+
* - Gradient: { backgroundImage: "linear-gradient(...)" }
|
|
188
|
+
*
|
|
189
|
+
* CSS requires `background-image` for gradients, not `background-color`.
|
|
190
|
+
*/
|
|
191
|
+
export function colorToCSSProperty(
|
|
192
|
+
value: ColorField,
|
|
193
|
+
opacity?: number // 0-100, only applies to solids
|
|
194
|
+
): Record<string, string> {
|
|
195
|
+
if (typeof value === "string") {
|
|
196
|
+
if (opacity !== undefined && opacity < 100) {
|
|
197
|
+
return { backgroundColor: hexToRgba(value, opacity / 100) };
|
|
198
|
+
}
|
|
199
|
+
return { backgroundColor: value };
|
|
200
|
+
}
|
|
201
|
+
// Gradients: opacity is ignored (each stop has its own alpha)
|
|
202
|
+
if (opacity !== undefined && !_gradientOpacityWarned) {
|
|
203
|
+
_gradientOpacityWarned = true;
|
|
204
|
+
console.warn(
|
|
205
|
+
"[@morphika/andami] background_opacity is ignored for gradient colors. " +
|
|
206
|
+
"Control opacity per stop in the gradient editor."
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
// Mesh gradients need separate backgroundImage + backgroundColor
|
|
210
|
+
// because a plain color (#hex) is not valid inside background-image.
|
|
211
|
+
if (value.type === "mesh") {
|
|
212
|
+
const layers = value.points
|
|
213
|
+
.map(
|
|
214
|
+
(p) =>
|
|
215
|
+
`radial-gradient(at ${p.x}% ${p.y}%, ${p.color} 0%, transparent 50%)`
|
|
216
|
+
)
|
|
217
|
+
.join(", ");
|
|
218
|
+
return { backgroundImage: layers, backgroundColor: value.background };
|
|
219
|
+
}
|
|
220
|
+
return { backgroundImage: colorToCSS(value) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Generates a CSS override rule string for responsive overrides.
|
|
225
|
+
* Returns the correct rule based on color type (with !important).
|
|
226
|
+
*/
|
|
227
|
+
export function colorToOverrideRule(
|
|
228
|
+
value: ColorField,
|
|
229
|
+
opacity?: number
|
|
230
|
+
): string {
|
|
231
|
+
if (typeof value === "string") {
|
|
232
|
+
if (opacity !== undefined && opacity < 100) {
|
|
233
|
+
return `background-color:${hexToRgba(value, opacity / 100)}!important`;
|
|
234
|
+
}
|
|
235
|
+
return `background-color:${value}!important`;
|
|
236
|
+
}
|
|
237
|
+
if (opacity !== undefined && !_gradientOpacityWarned) {
|
|
238
|
+
_gradientOpacityWarned = true;
|
|
239
|
+
console.warn(
|
|
240
|
+
"[@morphika/andami] background_opacity is ignored for gradient colors. " +
|
|
241
|
+
"Control opacity per stop in the gradient editor."
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
// Mesh gradients need two rules: background-image for layers + background-color for base
|
|
245
|
+
if (value.type === "mesh") {
|
|
246
|
+
const layers = value.points
|
|
247
|
+
.map(
|
|
248
|
+
(p) =>
|
|
249
|
+
`radial-gradient(at ${p.x}% ${p.y}%, ${p.color} 0%, transparent 50%)`
|
|
250
|
+
)
|
|
251
|
+
.join(", ");
|
|
252
|
+
return `background-image:${layers}!important;background-color:${value.background}!important`;
|
|
253
|
+
}
|
|
254
|
+
return `background-image:${colorToCSS(value)}!important`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Generates CSS for border with color.
|
|
259
|
+
* - Solid: standard border shorthand.
|
|
260
|
+
* - Gradient: border-image.
|
|
261
|
+
*/
|
|
262
|
+
export function borderColorToCSS(
|
|
263
|
+
value: ColorField,
|
|
264
|
+
width: number,
|
|
265
|
+
style: string
|
|
266
|
+
): Record<string, string> {
|
|
267
|
+
if (typeof value === "string") {
|
|
268
|
+
return { border: `${width}px ${style} ${value}` };
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
border: `${width}px ${style} transparent`,
|
|
272
|
+
borderImage: `${colorToCSS(value)} 1`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Generates a CSS override rule for border-color in responsive overrides.
|
|
278
|
+
* - Solid: `border-color:X!important`
|
|
279
|
+
* - Gradient: `border-image:X 1!important;border-color:transparent!important`
|
|
280
|
+
*/
|
|
281
|
+
export function borderColorToOverrideRule(value: ColorField): string {
|
|
282
|
+
if (typeof value === "string") {
|
|
283
|
+
return `border-color:${value}!important`;
|
|
284
|
+
}
|
|
285
|
+
return `border-color:transparent!important;border-image:${colorToCSS(value)} 1!important`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Parse a raw Sanity field value into a typed ColorField.
|
|
290
|
+
* - Hex strings pass through as-is
|
|
291
|
+
* - JSON strings starting with "{" are parsed as GradientValue
|
|
292
|
+
* - Anything else falls back to "#000000"
|
|
293
|
+
*/
|
|
294
|
+
export function parseColorField(raw: unknown): ColorField {
|
|
295
|
+
if (typeof raw === "string") {
|
|
296
|
+
if (raw.startsWith("{")) {
|
|
297
|
+
try {
|
|
298
|
+
return JSON.parse(raw) as GradientValue;
|
|
299
|
+
} catch {
|
|
300
|
+
return raw;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return raw;
|
|
304
|
+
}
|
|
305
|
+
return "#000000";
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Serialize a ColorField for storage in Sanity.
|
|
310
|
+
* - Hex strings are stored as-is
|
|
311
|
+
* - Gradient objects are JSON-stringified
|
|
312
|
+
*/
|
|
313
|
+
export function serializeColorField(value: ColorField): string {
|
|
314
|
+
if (typeof value === "string") return value;
|
|
315
|
+
return JSON.stringify(value);
|
|
316
|
+
}
|
package/lib/config/index.ts
CHANGED
|
@@ -1,57 +1,28 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
2
|
* Site configuration accessor.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `app/layout.tsx` imports its own `site.config.ts` and passes it here.
|
|
4
|
+
* Uses globalThis + Symbol.for to guarantee a true singleton even when
|
|
5
|
+
* the bundler creates multiple module instances of this file.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
import type { SiteConfig } from "./types";
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
const CONFIG_KEY = Symbol.for("@morphika/andami/siteConfig");
|
|
11
|
+
const g = globalThis as unknown as Record<symbol, SiteConfig | undefined>;
|
|
13
12
|
|
|
14
|
-
/** Cached reference. */
|
|
15
|
-
let _resolved: SiteConfig | null = null;
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Register the site configuration.
|
|
19
|
-
*
|
|
20
|
-
* Call this once at app startup (root layout module scope) before any
|
|
21
|
-
* component or API route calls `getSiteConfig()`. The instance's
|
|
22
|
-
* `app/layout.tsx` imports its own `site.config.ts` and passes it here.
|
|
23
|
-
*
|
|
24
|
-
* Calling this more than once replaces the previous config (useful for
|
|
25
|
-
* testing, but not expected in production).
|
|
26
|
-
*/
|
|
27
13
|
export function registerConfig(config: SiteConfig): void {
|
|
28
|
-
|
|
29
|
-
// Invalidate resolved cache so next getSiteConfig() picks up the new value.
|
|
30
|
-
_resolved = null;
|
|
14
|
+
g[CONFIG_KEY] = config;
|
|
31
15
|
}
|
|
32
16
|
|
|
33
|
-
/**
|
|
34
|
-
* Get the site configuration.
|
|
35
|
-
*
|
|
36
|
-
* Returns the config registered via `registerConfig()`.
|
|
37
|
-
* Throws if no config has been registered yet.
|
|
38
|
-
*
|
|
39
|
-
* Returns the same object reference on every call (singleton).
|
|
40
|
-
* Safe to call from both server and client components.
|
|
41
|
-
*/
|
|
42
17
|
export function getSiteConfig(): SiteConfig {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"See: https://github.com/MorphikaStudio/Morphika_Andami#quick-start",
|
|
50
|
-
);
|
|
51
|
-
}
|
|
18
|
+
const cfg = g[CONFIG_KEY];
|
|
19
|
+
if (!cfg) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
"SiteConfig not registered. Call registerConfig(config) in your root layout before using getSiteConfig().\n" +
|
|
22
|
+
"See: https://github.com/MorphikaStudio/Morphika_Andami#quick-start",
|
|
23
|
+
);
|
|
52
24
|
}
|
|
53
|
-
return
|
|
25
|
+
return cfg;
|
|
54
26
|
}
|
|
55
27
|
|
|
56
|
-
|
|
57
|
-
export type { SiteConfig } from "./types";
|
|
28
|
+
export type { SiteConfig } from "./types";
|
package/lib/revalidate.ts
CHANGED