@morphika/andami 0.1.3 → 0.1.6
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 +7 -4
- package/app/(site)/layout.tsx +5 -2
- package/app/(site)/page.tsx +2 -2
- package/app/(site)/preview/page.tsx +4 -4
- package/app/(site)/work/[slug]/page.tsx +7 -4
- package/app/admin/layout.tsx +3 -2
- package/app/admin/login/page.tsx +5 -5
- package/app/admin/navigation/page.tsx +255 -157
- package/app/api/admin/assets/health/route.ts +1 -1
- package/app/api/admin/assets/register/route.ts +1 -1
- package/app/api/admin/assets/registry/route.ts +1 -1
- package/app/api/admin/assets/relink/confirm/route.ts +2 -2
- package/app/api/admin/assets/relink/route.ts +1 -1
- package/app/api/admin/assets/scan/route.ts +1 -1
- package/app/api/admin/custom-sections/[slug]/route.ts +1 -1
- package/app/api/admin/custom-sections/route.ts +1 -1
- package/app/api/admin/database/route.ts +1 -1
- package/app/api/admin/pages/[slug]/duplicate/route.ts +1 -1
- package/app/api/admin/pages/[slug]/route.ts +2 -2
- package/app/api/admin/pages/[slug]/set-home/route.ts +1 -1
- package/app/api/admin/pages/route.ts +1 -1
- package/app/api/admin/preview/route.ts +1 -1
- package/app/api/admin/r2/delete/route.ts +1 -1
- package/app/api/admin/r2/rename/route.ts +1 -1
- package/app/api/admin/r2/status/route.ts +1 -1
- package/app/api/admin/r2/upload-url/route.ts +1 -1
- package/app/api/admin/settings/route.ts +41 -16
- package/app/api/admin/setup/complete/route.ts +2 -2
- package/app/api/admin/setup/route.ts +7 -4
- package/app/api/admin/storage/switch/route.ts +1 -1
- package/app/api/admin/styles/route.ts +1 -1
- 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 +97 -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/revalidate.ts +2 -2
- package/lib/sanity/client.ts +16 -0
- package/lib/sanity/queries.ts +4 -3
- package/lib/sanity/types.ts +76 -1
- package/lib/setup/detect.ts +1 -1
- package/lib/storage/index.ts +22 -4
- package/lib/version.ts +6 -0
- package/package.json +8 -2
- package/sanity/schemas/siteSettings.ts +34 -0
- package/styles/base.css +3 -3
- 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/revalidate.ts
CHANGED
package/lib/sanity/client.ts
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
import { createClient } from "next-sanity";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Public read client — uses Sanity CDN for fast edge-cached responses.
|
|
5
|
+
* Stale window: ~120s (acceptable since ISR revalidates every 1h).
|
|
6
|
+
* Used by: public pages, public API routes, asset proxy.
|
|
7
|
+
*/
|
|
3
8
|
export const client = createClient({
|
|
9
|
+
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
|
|
10
|
+
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || "production",
|
|
11
|
+
apiVersion: "2024-01-01",
|
|
12
|
+
useCdn: true,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Admin read client — bypasses CDN for always-fresh data.
|
|
17
|
+
* Used by: admin API routes that read content for editing.
|
|
18
|
+
*/
|
|
19
|
+
export const adminClient = createClient({
|
|
4
20
|
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
|
|
5
21
|
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || "production",
|
|
6
22
|
apiVersion: "2024-01-01",
|
package/lib/sanity/queries.ts
CHANGED
|
@@ -209,7 +209,7 @@ export const publishedProjectSummariesQuery = groq`
|
|
|
209
209
|
|
|
210
210
|
// All non-project page slugs (for generateStaticParams on /[slug])
|
|
211
211
|
export const allPageSlugsQuery = groq`
|
|
212
|
-
*[_type == "page" && page_type != "project" && draft_mode != true].slug.current
|
|
212
|
+
*[_type == "page" && page_type != "project" && draft_mode != true && defined(slug.current)].slug.current
|
|
213
213
|
`;
|
|
214
214
|
|
|
215
215
|
// Get a published project page by slug (for public /work/[slug] route)
|
|
@@ -231,7 +231,7 @@ export const publishedProjectBySlugQuery = groq`
|
|
|
231
231
|
|
|
232
232
|
// All published project slugs (for generateStaticParams on /work/[slug])
|
|
233
233
|
export const allProjectSlugsQuery = groq`
|
|
234
|
-
*[_type == "page" && page_type == "project" && draft_mode != true].slug.current
|
|
234
|
+
*[_type == "page" && page_type == "project" && draft_mode != true && defined(slug.current)].slug.current
|
|
235
235
|
`;
|
|
236
236
|
|
|
237
237
|
// ============================================
|
|
@@ -375,7 +375,7 @@ export const siteStylesQuery = groq`
|
|
|
375
375
|
// ============================================
|
|
376
376
|
|
|
377
377
|
export const siteSettingsQuery = groq`
|
|
378
|
-
*[
|
|
378
|
+
*[_id == "siteSettings"][0] {
|
|
379
379
|
nav_items[] {
|
|
380
380
|
_key,
|
|
381
381
|
type,
|
|
@@ -398,6 +398,7 @@ export const siteSettingsQuery = groq`
|
|
|
398
398
|
style_overrides
|
|
399
399
|
},
|
|
400
400
|
nav_design,
|
|
401
|
+
nav_mobile_design,
|
|
401
402
|
default_title,
|
|
402
403
|
default_description,
|
|
403
404
|
default_og_image,
|