@morphika/andami 0.5.0 → 0.5.1
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/README.md +151 -36
- package/app/admin/layout.tsx +145 -152
- package/components/blocks/AudioBlockRenderer.tsx +286 -0
- package/components/blocks/BeforeAfterBlockRenderer.tsx +274 -0
- package/components/builder/BlockCardIcons.tsx +89 -0
- package/components/builder/BlockTypePicker.tsx +2 -0
- package/components/builder/CoverSectionCanvas.tsx +90 -2
- package/components/builder/SectionV2Canvas.tsx +19 -3
- package/components/builder/SectionV2Column.tsx +5 -1
- package/components/builder/asset-browser/R2BrowserContent.tsx +23 -6
- package/components/builder/asset-browser/helpers.ts +4 -0
- package/components/builder/asset-browser/types.ts +2 -1
- package/components/builder/blockStyles.tsx +12 -0
- package/components/builder/editors/AudioBlockEditor.tsx +242 -0
- package/components/builder/editors/BeforeAfterBlockEditor.tsx +360 -0
- package/components/builder/editors/shared.tsx +1 -1
- package/components/builder/live-preview/LiveAudioPreview.tsx +120 -0
- package/components/builder/live-preview/LiveBeforeAfterPreview.tsx +176 -0
- package/lib/animation/enter-types.ts +2 -0
- package/lib/animation/hover-effect-types.ts +2 -0
- package/lib/builder/block-registrations.ts +83 -1
- package/lib/builder/types.ts +2 -0
- package/lib/sanity/types.ts +58 -0
- package/lib/version.ts +1 -1
- package/package.json +1 -1
- package/sanity/schemas/blocks/audioBlock.ts +69 -0
- package/sanity/schemas/blocks/beforeAfterBlock.ts +121 -0
- package/sanity/schemas/blocks/index.ts +3 -1
- package/sanity/schemas/index.ts +7 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useMemo, useState } from "react";
|
|
3
|
+
import { useCallback, useMemo, useState } from "react";
|
|
4
4
|
import { useBuilderStore } from "../../lib/builder/store";
|
|
5
|
-
import type { CoverSection, CoverRow, PageSectionV2 } from "../../lib/sanity/types";
|
|
5
|
+
import type { CoverSection, CoverRow, PageSectionV2, ColumnOverride } from "../../lib/sanity/types";
|
|
6
6
|
import type { DeviceViewport } from "../../lib/builder/types";
|
|
7
7
|
import SectionV2Canvas from "./SectionV2Canvas";
|
|
8
8
|
import CoverRowResizeHandle from "./CoverRowResizeHandle";
|
|
@@ -84,9 +84,37 @@ export default function CoverSectionCanvas({
|
|
|
84
84
|
const virtualSectionsPerRow = useMemo(() => {
|
|
85
85
|
return effectiveRows.map((row, rowIndex) => {
|
|
86
86
|
const rowNumber = rowIndex + 1;
|
|
87
|
+
const rowColumnKeys = new Set(
|
|
88
|
+
section.columns.filter((c) => c.grid_row === rowNumber).map((c) => c._key),
|
|
89
|
+
);
|
|
87
90
|
const rowColumns = section.columns
|
|
88
91
|
.filter((c) => c.grid_row === rowNumber)
|
|
89
92
|
.map((c) => ({ ...c, grid_row: 1 }));
|
|
93
|
+
|
|
94
|
+
// Project the Cover's responsive overrides into this row's virtual section.
|
|
95
|
+
// Only keep column overrides whose `_key` belongs to this row, and remap their
|
|
96
|
+
// `grid_row` from the Cover's actual row number back to 1 (the virtual row).
|
|
97
|
+
// Without this, SectionV2Canvas calls `getEffectiveColumnsV2` against the virtual
|
|
98
|
+
// section and never sees the overrides — tablet/phone view falls back to the
|
|
99
|
+
// desktop layout even when overrides exist.
|
|
100
|
+
const projectOverridesForRow = (
|
|
101
|
+
overrides: ColumnOverride[] | undefined,
|
|
102
|
+
): ColumnOverride[] | undefined => {
|
|
103
|
+
if (!overrides) return undefined;
|
|
104
|
+
const filtered = overrides
|
|
105
|
+
.filter((o) => rowColumnKeys.has(o._key))
|
|
106
|
+
.map((o) =>
|
|
107
|
+
o.grid_row !== undefined ? { ...o, grid_row: 1 } : o,
|
|
108
|
+
);
|
|
109
|
+
return filtered.length ? filtered : undefined;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const virtualResponsive: PageSectionV2["responsive"] = {};
|
|
113
|
+
const tabletCols = projectOverridesForRow(section.responsive?.tablet?.columns);
|
|
114
|
+
const phoneCols = projectOverridesForRow(section.responsive?.phone?.columns);
|
|
115
|
+
if (tabletCols) virtualResponsive.tablet = { columns: tabletCols };
|
|
116
|
+
if (phoneCols) virtualResponsive.phone = { columns: phoneCols };
|
|
117
|
+
|
|
90
118
|
const virtualSection: PageSectionV2 = {
|
|
91
119
|
_type: "pageSectionV2",
|
|
92
120
|
_key: section._key,
|
|
@@ -98,11 +126,70 @@ export default function CoverSectionCanvas({
|
|
|
98
126
|
col_gap: section.settings.col_gap ?? 20,
|
|
99
127
|
row_gap: section.settings.row_gap ?? 20,
|
|
100
128
|
},
|
|
129
|
+
...(Object.keys(virtualResponsive).length > 0
|
|
130
|
+
? { responsive: virtualResponsive }
|
|
131
|
+
: {}),
|
|
101
132
|
};
|
|
102
133
|
return { row, rowNumber, virtualSection };
|
|
103
134
|
});
|
|
104
135
|
}, [effectiveRows, section]);
|
|
105
136
|
|
|
137
|
+
// Custom responsive updater for virtual per-row sections. Takes the responsive
|
|
138
|
+
// object that SectionV2Canvas built (with `grid_row: 1` for this row's columns
|
|
139
|
+
// and keys limited to this row) and merges it back into the Cover's flat
|
|
140
|
+
// column override list: remaps `grid_row: 1` back to `rowNumber`, preserves
|
|
141
|
+
// overrides for other rows, and preserves `cover_rows` + `settings` at the
|
|
142
|
+
// responsive level which are invisible to SectionV2Canvas.
|
|
143
|
+
const handleVirtualRowResponsiveUpdate = useCallback(
|
|
144
|
+
(rowNumber: number) =>
|
|
145
|
+
(_sectionKey: string, incoming: PageSectionV2["responsive"]): void => {
|
|
146
|
+
const rowColumnKeys = new Set(
|
|
147
|
+
section.columns.filter((c) => c.grid_row === rowNumber).map((c) => c._key),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const merged: CoverSection["responsive"] = {};
|
|
151
|
+
|
|
152
|
+
(["tablet", "phone"] as const).forEach((vp) => {
|
|
153
|
+
const existingVp = section.responsive?.[vp];
|
|
154
|
+
const incomingVp = incoming?.[vp];
|
|
155
|
+
|
|
156
|
+
// Columns for OTHER rows from the Cover's existing responsive stay as-is.
|
|
157
|
+
const otherRowOverrides = (existingVp?.columns || []).filter(
|
|
158
|
+
(o) => !rowColumnKeys.has(o._key),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// Columns for THIS row come from the incoming virtual-section responsive,
|
|
162
|
+
// with grid_row remapped 1 → rowNumber.
|
|
163
|
+
const thisRowOverrides = (incomingVp?.columns || []).map((o) =>
|
|
164
|
+
o.grid_row !== undefined ? { ...o, grid_row: rowNumber } : o,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const mergedCols = [...otherRowOverrides, ...thisRowOverrides];
|
|
168
|
+
|
|
169
|
+
const vpOverride: NonNullable<CoverSection["responsive"]>[typeof vp] = {};
|
|
170
|
+
if (mergedCols.length > 0) vpOverride.columns = mergedCols;
|
|
171
|
+
// Preserve cover_rows + settings that the virtual section never sees.
|
|
172
|
+
if (existingVp?.cover_rows) vpOverride.cover_rows = existingVp.cover_rows;
|
|
173
|
+
if (existingVp?.settings) vpOverride.settings = existingVp.settings;
|
|
174
|
+
|
|
175
|
+
if (Object.keys(vpOverride).length > 0) {
|
|
176
|
+
merged[vp] = vpOverride;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const finalResponsive = Object.keys(merged).length > 0 ? merged : undefined;
|
|
181
|
+
// CoverSection and PageSectionV2 share the same `updateSectionV2Responsive`
|
|
182
|
+
// store action (it writes to any section by _key); the runtime shapes align
|
|
183
|
+
// on `{ tablet?, phone? }` — the extra `cover_rows` field on CoverSection
|
|
184
|
+
// is carried through without touching the V2-shaped write path.
|
|
185
|
+
store.updateSectionV2Responsive(
|
|
186
|
+
section._key,
|
|
187
|
+
finalResponsive as PageSectionV2["responsive"],
|
|
188
|
+
);
|
|
189
|
+
},
|
|
190
|
+
[section, store],
|
|
191
|
+
);
|
|
192
|
+
|
|
106
193
|
return (
|
|
107
194
|
<div
|
|
108
195
|
className="relative"
|
|
@@ -219,6 +306,7 @@ export default function CoverSectionCanvas({
|
|
|
219
306
|
onAddBlockTarget={onAddBlockTarget}
|
|
220
307
|
fillHeight
|
|
221
308
|
gridRowOffset={rowNumber - 1}
|
|
309
|
+
onUpdateResponsive={handleVirtualRowResponsiveUpdate(rowNumber)}
|
|
222
310
|
/>
|
|
223
311
|
</div>
|
|
224
312
|
) : (
|
|
@@ -27,6 +27,13 @@ interface SectionV2CanvasProps {
|
|
|
27
27
|
fillHeight?: boolean;
|
|
28
28
|
/** Offset added to grid_row when adding columns via gaps (used by cover sections where columns are normalized to grid_row 1) */
|
|
29
29
|
gridRowOffset?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Optional override for the responsive-update action. When provided, used instead of the
|
|
32
|
+
* store's `updateSectionV2Responsive`. Used by CoverSectionCanvas to intercept writes from
|
|
33
|
+
* virtual per-row sections and merge them back into the Cover's flat column list (remapping
|
|
34
|
+
* `grid_row: 1` back to the row's real number and preserving overrides for other rows).
|
|
35
|
+
*/
|
|
36
|
+
onUpdateResponsive?: (sectionKey: string, responsive: PageSectionV2["responsive"]) => void;
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
export default function SectionV2Canvas({
|
|
@@ -34,6 +41,7 @@ export default function SectionV2Canvas({
|
|
|
34
41
|
onAddBlockTarget,
|
|
35
42
|
fillHeight,
|
|
36
43
|
gridRowOffset = 0,
|
|
44
|
+
onUpdateResponsive,
|
|
37
45
|
}: SectionV2CanvasProps) {
|
|
38
46
|
const previewMode = useBuilderStore((s) => s.previewMode);
|
|
39
47
|
const canvasZoom = useBuilderStore((s) => s.canvasZoom);
|
|
@@ -44,7 +52,8 @@ export default function SectionV2Canvas({
|
|
|
44
52
|
const deleteColumnV2 = useBuilderStore((s) => s.deleteColumnV2);
|
|
45
53
|
const resizeColumnV2 = useBuilderStore((s) => s.resizeColumnV2);
|
|
46
54
|
const resizeColumnV2Left = useBuilderStore((s) => s.resizeColumnV2Left);
|
|
47
|
-
const
|
|
55
|
+
const storeUpdateSectionV2Responsive = useBuilderStore((s) => s.updateSectionV2Responsive);
|
|
56
|
+
const updateSectionV2Responsive = onUpdateResponsive ?? storeUpdateSectionV2Responsive;
|
|
48
57
|
const selectColumnV2 = useBuilderStore((s) => s.selectColumnV2);
|
|
49
58
|
const selectBlock = useBuilderStore((s) => s.selectBlock);
|
|
50
59
|
const deleteBlock = useBuilderStore((s) => s.deleteBlock);
|
|
@@ -153,7 +162,10 @@ export default function SectionV2Canvas({
|
|
|
153
162
|
style={{
|
|
154
163
|
display: "grid",
|
|
155
164
|
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
|
|
156
|
-
|
|
165
|
+
// `minmax(0, 1fr)` lets the row shrink below intrinsic content min-size
|
|
166
|
+
// (gaps' minHeight, empty-column wrappers), which is required in Cover
|
|
167
|
+
// sections where the row has a strict proportional height.
|
|
168
|
+
...(fillHeight ? { gridTemplateRows: "minmax(0, 1fr)" } : {}),
|
|
157
169
|
columnGap: `${colGap}px`,
|
|
158
170
|
rowGap: `${rowGap}px`,
|
|
159
171
|
position: "relative",
|
|
@@ -237,6 +249,7 @@ export default function SectionV2Canvas({
|
|
|
237
249
|
}
|
|
238
250
|
onResizeRight={handleResizeRight}
|
|
239
251
|
onResizeLeft={handleResizeLeft}
|
|
252
|
+
fillHeight={fillHeight}
|
|
240
253
|
>
|
|
241
254
|
{(col.blocks || []).map((block, blockIdx) => (
|
|
242
255
|
<SortableBlock
|
|
@@ -286,7 +299,10 @@ export default function SectionV2Canvas({
|
|
|
286
299
|
style={{
|
|
287
300
|
gridColumn: `${gap.grid_column} / span ${gap.span}`,
|
|
288
301
|
gridRow: gap.grid_row,
|
|
289
|
-
|
|
302
|
+
// In fillHeight (Cover), the row has a strict proportional
|
|
303
|
+
// height — don't let the gap force a minimum that would push
|
|
304
|
+
// content past the row boundary.
|
|
305
|
+
minHeight: fillHeight ? 0 : 70,
|
|
290
306
|
}}
|
|
291
307
|
className={`rounded-lg border-2 border-dashed text-xs font-medium transition-all flex items-center justify-center cursor-pointer ${
|
|
292
308
|
isGapTarget
|
|
@@ -131,6 +131,9 @@ interface SectionV2ColumnProps {
|
|
|
131
131
|
onAddBlock: (insertIndex?: number) => void;
|
|
132
132
|
onResizeRight: (columnKey: string, startX: number, startSpan: number, containerEl: HTMLElement) => void;
|
|
133
133
|
onResizeLeft: (columnKey: string, startX: number, startGridCol: number, startSpan: number, containerEl: HTMLElement) => void;
|
|
134
|
+
/** When true, the column lives in a Cover row with strict proportional height —
|
|
135
|
+
* empty-state minHeight is relaxed so the column doesn't overflow its row. */
|
|
136
|
+
fillHeight?: boolean;
|
|
134
137
|
children: ReactNode;
|
|
135
138
|
}
|
|
136
139
|
|
|
@@ -152,6 +155,7 @@ export default function SectionV2Column({
|
|
|
152
155
|
onAddBlock,
|
|
153
156
|
onResizeRight,
|
|
154
157
|
onResizeLeft,
|
|
158
|
+
fillHeight = false,
|
|
155
159
|
children,
|
|
156
160
|
}: SectionV2ColumnProps) {
|
|
157
161
|
const previewMode = useBuilderStore((s) => s.previewMode);
|
|
@@ -453,7 +457,7 @@ export default function SectionV2Column({
|
|
|
453
457
|
/* Empty column: show + Add Block (flex-1 stretches in fillHeight cover sections) */
|
|
454
458
|
<div
|
|
455
459
|
className="relative flex items-center justify-center flex-1"
|
|
456
|
-
style={{ minHeight: 80, padding: "16px 12px" }}
|
|
460
|
+
style={{ minHeight: fillHeight ? 0 : 80, padding: "16px 12px" }}
|
|
457
461
|
>
|
|
458
462
|
<button
|
|
459
463
|
onClick={handleAddBlockEmpty}
|
|
@@ -4,7 +4,7 @@ import { useState, useMemo, useCallback, useRef, useEffect, Fragment } from "rea
|
|
|
4
4
|
import type { RegisteredAsset } from "../../../lib/sanity/types";
|
|
5
5
|
import { VirtualAssetGrid } from "../VirtualAssetGrid";
|
|
6
6
|
import type { UploadingFile } from "./types";
|
|
7
|
-
import { formatFileSize, isImageType, isVideoType, isFontType, buildFolderTree } from "./helpers";
|
|
7
|
+
import { formatFileSize, isImageType, isVideoType, isAudioType, isFontType, buildFolderTree } from "./helpers";
|
|
8
8
|
import { FolderTreeItem } from "./FolderTreeItem";
|
|
9
9
|
import { VideoThumbnail } from "./VideoThumbnail";
|
|
10
10
|
import { FileLightbox } from "./FileLightbox";
|
|
@@ -34,7 +34,7 @@ export function R2BrowserContent({
|
|
|
34
34
|
setSelectedAsset: (a: RegisteredAsset | null) => void;
|
|
35
35
|
onRetry: () => void;
|
|
36
36
|
onDoubleClick?: (asset: RegisteredAsset) => void;
|
|
37
|
-
filterType?: "image" | "video" | "all";
|
|
37
|
+
filterType?: "image" | "video" | "audio" | "all";
|
|
38
38
|
multiSelect?: boolean;
|
|
39
39
|
selectedAssets?: RegisteredAsset[];
|
|
40
40
|
setSelectedAssets?: (assets: RegisteredAsset[]) => void;
|
|
@@ -99,6 +99,7 @@ export function R2BrowserContent({
|
|
|
99
99
|
|
|
100
100
|
if (filterType === "image") filtered = filtered.filter((a) => isImageType(a.extension));
|
|
101
101
|
else if (filterType === "video") filtered = filtered.filter((a) => isVideoType(a.extension));
|
|
102
|
+
else if (filterType === "audio") filtered = filtered.filter((a) => isAudioType(a.extension));
|
|
102
103
|
|
|
103
104
|
if (searchQuery.trim()) {
|
|
104
105
|
const q = searchQuery.trim().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
|
|
@@ -106,8 +107,12 @@ export function R2BrowserContent({
|
|
|
106
107
|
(a) => {
|
|
107
108
|
const nameNorm = a.filename.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
|
|
108
109
|
const pathNorm = a.path.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
const matchesType =
|
|
111
|
+
filterType === "all" ||
|
|
112
|
+
(filterType === "image" && isImageType(a.extension)) ||
|
|
113
|
+
(filterType === "video" && isVideoType(a.extension)) ||
|
|
114
|
+
(filterType === "audio" && isAudioType(a.extension));
|
|
115
|
+
return a.filename !== ".folder" && (nameNorm.includes(q) || pathNorm.includes(q)) && matchesType;
|
|
111
116
|
}
|
|
112
117
|
);
|
|
113
118
|
}
|
|
@@ -185,6 +190,18 @@ export function R2BrowserContent({
|
|
|
185
190
|
);
|
|
186
191
|
}
|
|
187
192
|
|
|
193
|
+
if (isAudioType(asset.extension)) {
|
|
194
|
+
return (
|
|
195
|
+
<div className="w-full h-full flex items-center justify-center bg-neutral-50">
|
|
196
|
+
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-neutral-300">
|
|
197
|
+
<path d="M9 18V5l12-2v13" />
|
|
198
|
+
<circle cx="6" cy="18" r="3" />
|
|
199
|
+
<circle cx="18" cy="16" r="3" />
|
|
200
|
+
</svg>
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
188
205
|
return (
|
|
189
206
|
<div className="w-full h-full flex items-center justify-center bg-neutral-50">
|
|
190
207
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-neutral-300">
|
|
@@ -208,7 +225,7 @@ export function R2BrowserContent({
|
|
|
208
225
|
ref={ops.fileInputRef}
|
|
209
226
|
type="file"
|
|
210
227
|
multiple
|
|
211
|
-
accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml,video/mp4,video/webm,video/quicktime"
|
|
228
|
+
accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml,video/mp4,video/webm,video/quicktime,audio/mpeg,audio/wav,audio/ogg,audio/mp4,audio/aac,audio/flac"
|
|
212
229
|
className="hidden"
|
|
213
230
|
onChange={dnd.handleFileInputChange}
|
|
214
231
|
/>
|
|
@@ -227,7 +244,7 @@ export function R2BrowserContent({
|
|
|
227
244
|
<p className="text-sm font-medium text-[#076bff]">
|
|
228
245
|
Drop files or folders here{currentFolder ? ` to ${currentFolder}` : ""}
|
|
229
246
|
</p>
|
|
230
|
-
<p className="text-xs text-neutral-500">Supported formats: JPG, PNG, WebP, GIF, SVG, MP4, WebM, MOV</p>
|
|
247
|
+
<p className="text-xs text-neutral-500">Supported formats: JPG, PNG, WebP, GIF, SVG, MP4, WebM, MOV, MP3, WAV, OGG, M4A, AAC, FLAC</p>
|
|
231
248
|
<p className="text-xs text-neutral-400">Maximum file size: 500 MB</p>
|
|
232
249
|
</div>
|
|
233
250
|
</div>
|
|
@@ -62,6 +62,10 @@ export function isVideoType(ext: string): boolean {
|
|
|
62
62
|
return ["mp4", "webm", "mov"].includes(ext);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
export function isAudioType(ext: string): boolean {
|
|
66
|
+
return ["mp3", "wav", "ogg", "m4a", "aac", "flac"].includes(ext);
|
|
67
|
+
}
|
|
68
|
+
|
|
65
69
|
export function isFontType(ext: string): boolean {
|
|
66
70
|
return ["otf", "ttf", "woff", "woff2"].includes(ext);
|
|
67
71
|
}
|
|
@@ -17,6 +17,7 @@ export const MAX_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB per file
|
|
|
17
17
|
export const ALLOWED_EXTENSIONS = new Set([
|
|
18
18
|
"jpg", "jpeg", "png", "webp", "gif", "svg",
|
|
19
19
|
"mp4", "webm", "mov",
|
|
20
|
+
"mp3", "wav", "ogg", "m4a", "aac", "flac",
|
|
20
21
|
]);
|
|
21
22
|
|
|
22
23
|
// ============================================
|
|
@@ -27,7 +28,7 @@ export interface AssetBrowserProps {
|
|
|
27
28
|
open: boolean;
|
|
28
29
|
onSelect: (path: string) => void;
|
|
29
30
|
onClose: () => void;
|
|
30
|
-
filterType?: "image" | "video" | "all";
|
|
31
|
+
filterType?: "image" | "video" | "audio" | "all";
|
|
31
32
|
/** Enable multi-select mode: user can pick multiple assets at once */
|
|
32
33
|
multiSelect?: boolean;
|
|
33
34
|
/** Called with all selected paths when multiSelect is true */
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
VideoBlockCardIcon,
|
|
21
21
|
SpacerBlockCardIcon,
|
|
22
22
|
ButtonBlockCardIcon,
|
|
23
|
+
BeforeAfterBlockCardIcon,
|
|
24
|
+
AudioBlockCardIcon,
|
|
23
25
|
} from "./BlockCardIcons";
|
|
24
26
|
import {
|
|
25
27
|
CoverSectionCardIcon,
|
|
@@ -39,6 +41,8 @@ export const BLOCK_GRADIENTS: Record<string, string> = {
|
|
|
39
41
|
videoBlock: "linear-gradient(135deg, #ffb8d4 0%, #ffc8a8 50%, #ffe0b8 100%)",
|
|
40
42
|
spacerBlock: "linear-gradient(135deg, #d8d8e8 0%, #e8e8f0 50%, #f0f0f8 100%)",
|
|
41
43
|
buttonBlock: "linear-gradient(135deg, #ffb8e0 0%, #b8ffe8 50%, #a8ffd8 100%)",
|
|
44
|
+
beforeAfterBlock: "linear-gradient(135deg, #d8d8e8 0%, #a8c8ff 50%, #b8ffe0 100%)",
|
|
45
|
+
audioBlock: "linear-gradient(135deg, #a8c8ff 0%, #d8c0ff 50%, #e0b8ff 100%)",
|
|
42
46
|
projectGridBlock: "linear-gradient(135deg, #ffd4a8 0%, #ffe8b8 50%, #fff0c8 100%)",
|
|
43
47
|
coverSection: "linear-gradient(135deg, #b2f5ea 0%, #81e6d9 50%, #5eead4 100%)",
|
|
44
48
|
parallaxGroup: "linear-gradient(135deg, #c8a8ff 0%, #d8b8ff 50%, #e8d0ff 100%)",
|
|
@@ -83,6 +87,12 @@ export function SpacerBlockIcon({ size = 28 }: { size?: number }) {
|
|
|
83
87
|
export function ButtonBlockIcon({ size = 28 }: { size?: number }) {
|
|
84
88
|
return <span style={scaleToHeight(size)}><ButtonBlockCardIcon /></span>;
|
|
85
89
|
}
|
|
90
|
+
export function BeforeAfterBlockIcon({ size = 28 }: { size?: number }) {
|
|
91
|
+
return <span style={scaleToHeight(size)}><BeforeAfterBlockCardIcon /></span>;
|
|
92
|
+
}
|
|
93
|
+
export function AudioBlockIcon({ size = 28 }: { size?: number }) {
|
|
94
|
+
return <span style={scaleToHeight(size)}><AudioBlockCardIcon /></span>;
|
|
95
|
+
}
|
|
86
96
|
|
|
87
97
|
// ── Non-block context icons (compact wrappers of the section card icons) ──
|
|
88
98
|
|
|
@@ -162,6 +172,8 @@ export const BLOCK_ICON_COMPONENTS: Record<string, React.FC<{ size?: number }>>
|
|
|
162
172
|
videoBlock: VideoBlockIcon,
|
|
163
173
|
spacerBlock: SpacerBlockIcon,
|
|
164
174
|
buttonBlock: ButtonBlockIcon,
|
|
175
|
+
beforeAfterBlock: BeforeAfterBlockIcon,
|
|
176
|
+
audioBlock: AudioBlockIcon,
|
|
165
177
|
projectGridBlock: ProjectGridBlockIcon,
|
|
166
178
|
projectCarouselBlock: ProjectCarouselBlockIcon,
|
|
167
179
|
parallaxGroup: ParallaxGroupIcon,
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useBuilderStore } from "../../../lib/builder/store";
|
|
4
|
+
import { getEffectiveValue, setResponsiveOverride } from "../../../lib/builder/responsive";
|
|
5
|
+
import type { AudioBlock, ContentBlock } from "../../../lib/sanity/types";
|
|
6
|
+
import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
|
|
7
|
+
import { resolveColorHex } from "../../../lib/color-utils";
|
|
8
|
+
import {
|
|
9
|
+
SourceIcon,
|
|
10
|
+
LayoutIcon,
|
|
11
|
+
AppearanceIcon,
|
|
12
|
+
PlaybackIcon,
|
|
13
|
+
OptionsIcon,
|
|
14
|
+
} from "./section-icons";
|
|
15
|
+
import {
|
|
16
|
+
SettingsField,
|
|
17
|
+
SettingsSection,
|
|
18
|
+
StyledCheckbox,
|
|
19
|
+
AssetPathInput,
|
|
20
|
+
ViewportBadge,
|
|
21
|
+
ResponsiveField,
|
|
22
|
+
useActiveViewport,
|
|
23
|
+
INPUT_CLASS,
|
|
24
|
+
} from "./shared";
|
|
25
|
+
|
|
26
|
+
interface Props {
|
|
27
|
+
block: AudioBlock;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default function AudioBlockEditor({ block }: Props) {
|
|
31
|
+
const store = useBuilderStore();
|
|
32
|
+
const viewport = useActiveViewport();
|
|
33
|
+
const paletteSwatches = usePaletteSwatches();
|
|
34
|
+
|
|
35
|
+
const snapshotOnFocus = () => store._pushSnapshot();
|
|
36
|
+
|
|
37
|
+
const updateResponsive = (property: string, value: unknown) => {
|
|
38
|
+
if (viewport === "desktop") {
|
|
39
|
+
store.updateBlock(block._key, { [property]: value } as Partial<ContentBlock>);
|
|
40
|
+
} else {
|
|
41
|
+
const overrides = setResponsiveOverride(block as ContentBlock, viewport, property, value);
|
|
42
|
+
store.updateBlock(block._key, overrides as Partial<ContentBlock>);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const resetOverride = (property: string) => {
|
|
47
|
+
const overrides = setResponsiveOverride(block as ContentBlock, viewport, property, undefined);
|
|
48
|
+
store.updateBlock(block._key, overrides as Partial<ContentBlock>);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const update = (updates: Partial<AudioBlock>) => {
|
|
52
|
+
store.updateBlock(block._key, updates as Partial<ContentBlock>);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const updateDebounced = (updates: Partial<AudioBlock>) => {
|
|
56
|
+
store.updateBlockDebounced(block._key, updates as Partial<ContentBlock>);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const effectiveWidth = getEffectiveValue<string>(
|
|
60
|
+
block as ContentBlock, viewport, "width", block.width || "contained"
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<>
|
|
65
|
+
<ViewportBadge />
|
|
66
|
+
|
|
67
|
+
{/* ── Source ── */}
|
|
68
|
+
<SettingsSection title="Audio" defaultOpen icon={<SourceIcon />}>
|
|
69
|
+
<SettingsField label="Audio File" hint="mp3, wav, ogg, m4a, aac, flac">
|
|
70
|
+
<AssetPathInput
|
|
71
|
+
value={block.asset_path || ""}
|
|
72
|
+
onFocus={snapshotOnFocus}
|
|
73
|
+
onChange={(v) => updateDebounced({ asset_path: v })}
|
|
74
|
+
placeholder="projects/slug/track.mp3"
|
|
75
|
+
filterType="audio"
|
|
76
|
+
/>
|
|
77
|
+
</SettingsField>
|
|
78
|
+
|
|
79
|
+
<SettingsField label="Alt Text">
|
|
80
|
+
<input
|
|
81
|
+
type="text"
|
|
82
|
+
value={block.alt || ""}
|
|
83
|
+
onFocus={snapshotOnFocus}
|
|
84
|
+
onChange={(e) => updateDebounced({ alt: e.target.value })}
|
|
85
|
+
className={INPUT_CLASS}
|
|
86
|
+
placeholder="Describe the audio for accessibility"
|
|
87
|
+
/>
|
|
88
|
+
</SettingsField>
|
|
89
|
+
</SettingsSection>
|
|
90
|
+
|
|
91
|
+
{/* ── Metadata ── */}
|
|
92
|
+
<SettingsSection title="Metadata" icon={<OptionsIcon />}>
|
|
93
|
+
<SettingsField label="Title">
|
|
94
|
+
<input
|
|
95
|
+
type="text"
|
|
96
|
+
value={block.title || ""}
|
|
97
|
+
onFocus={snapshotOnFocus}
|
|
98
|
+
onChange={(e) => updateDebounced({ title: e.target.value })}
|
|
99
|
+
className={INPUT_CLASS}
|
|
100
|
+
placeholder="Track title"
|
|
101
|
+
/>
|
|
102
|
+
</SettingsField>
|
|
103
|
+
|
|
104
|
+
<SettingsField label="Artist">
|
|
105
|
+
<input
|
|
106
|
+
type="text"
|
|
107
|
+
value={block.artist || ""}
|
|
108
|
+
onFocus={snapshotOnFocus}
|
|
109
|
+
onChange={(e) => updateDebounced({ artist: e.target.value })}
|
|
110
|
+
className={INPUT_CLASS}
|
|
111
|
+
placeholder="Artist name"
|
|
112
|
+
/>
|
|
113
|
+
</SettingsField>
|
|
114
|
+
|
|
115
|
+
<SettingsField label="Cover Art" hint="Optional relative path to an image">
|
|
116
|
+
<AssetPathInput
|
|
117
|
+
value={block.cover_path || ""}
|
|
118
|
+
onFocus={snapshotOnFocus}
|
|
119
|
+
onChange={(v) => updateDebounced({ cover_path: v })}
|
|
120
|
+
placeholder="projects/slug/cover.jpg"
|
|
121
|
+
filterType="image"
|
|
122
|
+
/>
|
|
123
|
+
</SettingsField>
|
|
124
|
+
</SettingsSection>
|
|
125
|
+
|
|
126
|
+
{/* ── Layout ── */}
|
|
127
|
+
<SettingsSection title="Layout" icon={<LayoutIcon />}>
|
|
128
|
+
<ResponsiveField
|
|
129
|
+
label="Width"
|
|
130
|
+
block={block as ContentBlock}
|
|
131
|
+
property="width"
|
|
132
|
+
onReset={() => resetOverride("width")}
|
|
133
|
+
>
|
|
134
|
+
<div className="flex gap-1">
|
|
135
|
+
{(
|
|
136
|
+
[
|
|
137
|
+
{ value: "full", label: "Full" },
|
|
138
|
+
{ value: "contained", label: "Contained" },
|
|
139
|
+
{ value: "small", label: "Small" },
|
|
140
|
+
{ value: "fill", label: "Fill" },
|
|
141
|
+
] as const
|
|
142
|
+
).map((opt) => (
|
|
143
|
+
<button
|
|
144
|
+
key={opt.value}
|
|
145
|
+
onClick={() => updateResponsive("width", opt.value)}
|
|
146
|
+
className={`flex-1 rounded border py-1 text-xs transition-colors ${
|
|
147
|
+
effectiveWidth === opt.value
|
|
148
|
+
? "border-[#076bff] bg-[#076bff]/20 text-neutral-900"
|
|
149
|
+
: "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
|
|
150
|
+
}`}
|
|
151
|
+
>
|
|
152
|
+
{opt.label}
|
|
153
|
+
</button>
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
156
|
+
</ResponsiveField>
|
|
157
|
+
</SettingsSection>
|
|
158
|
+
|
|
159
|
+
{/* ── Appearance ── */}
|
|
160
|
+
<SettingsSection title="Appearance" icon={<AppearanceIcon />}>
|
|
161
|
+
<SettingsField label="Accent Color" hint="Play button + progress fill">
|
|
162
|
+
<ColorSwatchPicker
|
|
163
|
+
value={block.accent_color || "#4794E2"}
|
|
164
|
+
onChange={(value) => {
|
|
165
|
+
const hex = resolveColorHex(value) || "#4794E2";
|
|
166
|
+
update({ accent_color: hex });
|
|
167
|
+
}}
|
|
168
|
+
swatches={paletteSwatches}
|
|
169
|
+
/>
|
|
170
|
+
</SettingsField>
|
|
171
|
+
|
|
172
|
+
<ResponsiveField
|
|
173
|
+
label="Border Radius"
|
|
174
|
+
block={block as ContentBlock}
|
|
175
|
+
property="border_radius"
|
|
176
|
+
onReset={() => resetOverride("border_radius")}
|
|
177
|
+
>
|
|
178
|
+
<div className="flex items-center gap-1.5">
|
|
179
|
+
<input
|
|
180
|
+
type="number"
|
|
181
|
+
value={String(getEffectiveValue<string>(block as ContentBlock, viewport, "border_radius", block.border_radius || "")).replace(/px$/i, "")}
|
|
182
|
+
onFocus={snapshotOnFocus}
|
|
183
|
+
onChange={(e) => {
|
|
184
|
+
store._pushSnapshot();
|
|
185
|
+
updateResponsive("border_radius", e.target.value.replace(/[^0-9]/g, ""));
|
|
186
|
+
}}
|
|
187
|
+
className={INPUT_CLASS}
|
|
188
|
+
placeholder="12"
|
|
189
|
+
min={0}
|
|
190
|
+
/>
|
|
191
|
+
<span className="text-[10px] text-neutral-400 shrink-0">px</span>
|
|
192
|
+
</div>
|
|
193
|
+
</ResponsiveField>
|
|
194
|
+
|
|
195
|
+
<ResponsiveField
|
|
196
|
+
label="Shadow"
|
|
197
|
+
block={block as ContentBlock}
|
|
198
|
+
property="shadow"
|
|
199
|
+
onReset={() => resetOverride("shadow")}
|
|
200
|
+
>
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
onClick={() => {
|
|
204
|
+
const effectiveShadow = getEffectiveValue<boolean>(block as ContentBlock, viewport, "shadow", block.shadow || false);
|
|
205
|
+
updateResponsive("shadow", !effectiveShadow);
|
|
206
|
+
}}
|
|
207
|
+
className={`relative w-8 h-[18px] rounded-full transition-colors ${
|
|
208
|
+
getEffectiveValue<boolean>(block as ContentBlock, viewport, "shadow", block.shadow || false) ? "bg-[#076bff]" : "bg-neutral-200 hover:bg-neutral-300"
|
|
209
|
+
}`}
|
|
210
|
+
>
|
|
211
|
+
<span
|
|
212
|
+
className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow-sm transition-transform ${
|
|
213
|
+
getEffectiveValue<boolean>(block as ContentBlock, viewport, "shadow", block.shadow || false) ? "left-[16px]" : "left-[2px]"
|
|
214
|
+
}`}
|
|
215
|
+
/>
|
|
216
|
+
</button>
|
|
217
|
+
</ResponsiveField>
|
|
218
|
+
</SettingsSection>
|
|
219
|
+
|
|
220
|
+
{/* ── Playback ── */}
|
|
221
|
+
<SettingsSection title="Playback" icon={<PlaybackIcon />}>
|
|
222
|
+
<div className="space-y-1.5">
|
|
223
|
+
<StyledCheckbox
|
|
224
|
+
label="Autoplay"
|
|
225
|
+
checked={block.autoplay === true}
|
|
226
|
+
onChange={(checked) => update({ autoplay: checked })}
|
|
227
|
+
/>
|
|
228
|
+
<StyledCheckbox
|
|
229
|
+
label="Loop"
|
|
230
|
+
checked={block.loop === true}
|
|
231
|
+
onChange={(checked) => update({ loop: checked })}
|
|
232
|
+
/>
|
|
233
|
+
<StyledCheckbox
|
|
234
|
+
label="Muted"
|
|
235
|
+
checked={block.muted === true}
|
|
236
|
+
onChange={(checked) => update({ muted: checked })}
|
|
237
|
+
/>
|
|
238
|
+
</div>
|
|
239
|
+
</SettingsSection>
|
|
240
|
+
</>
|
|
241
|
+
);
|
|
242
|
+
}
|