@morphika/andami 0.5.4 → 0.5.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/admin/assets/page.tsx +3 -2
- package/app/admin/layout.tsx +4 -0
- package/components/admin/nav-builder/NavBuilder.tsx +2 -1
- package/components/admin/styles/FontsEditor.tsx +2 -1
- package/components/builder/ColumnDragOverlay.tsx +4 -4
- package/components/builder/CoverSectionCanvas.tsx +10 -9
- package/components/builder/InsertionLines.tsx +3 -3
- package/components/builder/SectionV2Canvas.tsx +3 -3
- package/components/builder/SectionV2Column.tsx +20 -20
- package/components/builder/SettingsPanel.tsx +14 -8
- package/components/builder/SortableBlock.tsx +4 -0
- package/components/builder/SortableRow.tsx +2 -0
- package/components/builder/asset-browser/useR2Operations.ts +5 -4
- package/components/builder/editors/AudioBlockEditor.tsx +10 -8
- package/components/builder/editors/BeforeAfterBlockEditor.tsx +10 -8
- package/components/builder/editors/ButtonBlockEditor.tsx +9 -7
- package/components/builder/editors/ImageBlockEditor.tsx +10 -8
- package/components/builder/editors/ImageGridBlockEditor.tsx +10 -8
- package/components/builder/editors/SpacerBlockEditor.tsx +4 -4
- package/components/builder/editors/TextBlockEditor.tsx +471 -468
- package/components/builder/editors/VideoBlockEditor.tsx +10 -8
- package/components/builder/live-preview/drag-utils.tsx +5 -3
- package/components/builder/settings-panel/AnimationTab.tsx +11 -8
- package/components/builder/settings-panel/BlockLayoutTab.tsx +514 -511
- package/components/builder/settings-panel/ColumnV2AnimationTab.tsx +2 -2
- package/components/builder/settings-panel/ColumnV2LayoutTab.tsx +11 -8
- package/components/builder/settings-panel/ColumnV2Settings.tsx +6 -5
- package/components/builder/settings-panel/CoverSectionLayoutTab.tsx +4 -3
- package/components/builder/settings-panel/CoverSectionSettings.tsx +14 -9
- package/components/builder/settings-panel/CustomSectionSettings.tsx +9 -7
- package/components/builder/settings-panel/PageSettings.tsx +39 -32
- package/components/builder/settings-panel/ParallaxGroupSettings.tsx +2 -2
- package/components/builder/settings-panel/ParallaxSlideSettings.tsx +2 -2
- package/components/builder/settings-panel/SectionV2AnimationTab.tsx +7 -5
- package/components/builder/settings-panel/SectionV2LayoutTab.tsx +13 -9
- package/components/builder/settings-panel/SectionV2Settings.tsx +10 -9
- package/components/builder/settings-panel/TRBLInputs.tsx +2 -2
- package/components/builder/settings-panel/useSettingsPanelSelection.ts +16 -13
- package/components/ui/ToastStack.tsx +142 -0
- package/lib/auth-token.ts +5 -1
- package/lib/bot-guard.ts +6 -0
- package/lib/builder/constants.ts +5 -10
- package/lib/toast/index.ts +56 -0
- package/lib/toast/store.ts +56 -0
- package/lib/version.ts +1 -1
- package/package.json +3 -1
|
@@ -1,511 +1,514 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* BlockLayoutTab — Per-block layout styling.
|
|
5
|
-
*
|
|
6
|
-
* Section order: Alignment → Spacing → Offset → Background → Border.
|
|
7
|
-
* Each section has a small visual icon next to its title.
|
|
8
|
-
*
|
|
9
|
-
* Session 78: Added Alignment section with visual icon buttons.
|
|
10
|
-
* Reordered sections. Added section title icons.
|
|
11
|
-
* Session 82: Made viewport-aware with responsive override support.
|
|
12
|
-
* Every property is now editable per viewport (desktop/tablet/phone).
|
|
13
|
-
* Uses getBlockLayoutValue/setBlockLayoutOverride from responsive-helpers.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { useBuilderStore } from "../../../lib/builder/store";
|
|
17
|
-
import type { ContentBlock, BlockLayout } from "../../../lib/sanity/types";
|
|
18
|
-
import {
|
|
19
|
-
SettingsField,
|
|
20
|
-
SettingsSection,
|
|
21
|
-
SELECT_CLASS,
|
|
22
|
-
AssetPathInput,
|
|
23
|
-
} from "../editors/shared";
|
|
24
|
-
import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
|
|
25
|
-
import { serializeColorField, parseColorField, isGradient } from "../../../lib/color-utils";
|
|
26
|
-
import { TRBLInputs } from "./TRBLInputs";
|
|
27
|
-
import { BubbleTooltip } from "../BubbleIcons";
|
|
28
|
-
import {
|
|
29
|
-
getBlockLayoutValue,
|
|
30
|
-
hasBlockLayoutOverride,
|
|
31
|
-
setBlockLayoutOverride,
|
|
32
|
-
} from "./responsive-helpers";
|
|
33
|
-
|
|
34
|
-
// ── Alignment visual icon buttons ──
|
|
35
|
-
|
|
36
|
-
type AlignH = "left" | "center" | "right";
|
|
37
|
-
type AlignV = "top" | "center" | "bottom";
|
|
38
|
-
|
|
39
|
-
function HAlignIcon({ value, size = 18 }: { value: AlignH; size?: number }) {
|
|
40
|
-
// Horizontal lines aligned left/center/right inside a box
|
|
41
|
-
const w = size;
|
|
42
|
-
const h = size;
|
|
43
|
-
const pad = 3;
|
|
44
|
-
const barH = 2.5;
|
|
45
|
-
const gap = 1.5;
|
|
46
|
-
const maxW = w - pad * 2;
|
|
47
|
-
|
|
48
|
-
const bars = [
|
|
49
|
-
{ w: maxW * 0.9, y: pad + 1 },
|
|
50
|
-
{ w: maxW * 0.55, y: pad + 1 + barH + gap },
|
|
51
|
-
{ w: maxW * 0.75, y: pad + 1 + (barH + gap) * 2 },
|
|
52
|
-
];
|
|
53
|
-
|
|
54
|
-
return (
|
|
55
|
-
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} fill="none">
|
|
56
|
-
{bars.map((bar, i) => {
|
|
57
|
-
let x = pad;
|
|
58
|
-
if (value === "center") x = (w - bar.w) / 2;
|
|
59
|
-
else if (value === "right") x = w - pad - bar.w;
|
|
60
|
-
return (
|
|
61
|
-
<rect
|
|
62
|
-
key={i}
|
|
63
|
-
x={x}
|
|
64
|
-
y={bar.y}
|
|
65
|
-
width={bar.w}
|
|
66
|
-
height={barH}
|
|
67
|
-
rx={1}
|
|
68
|
-
fill="currentColor"
|
|
69
|
-
/>
|
|
70
|
-
);
|
|
71
|
-
})}
|
|
72
|
-
</svg>
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function VAlignIcon({ value, size = 18 }: { value: AlignV; size?: number }) {
|
|
77
|
-
const w = size;
|
|
78
|
-
const h = size;
|
|
79
|
-
const pad = 3;
|
|
80
|
-
const barW = w - pad * 2;
|
|
81
|
-
const barH = 3;
|
|
82
|
-
|
|
83
|
-
// Position of a single bar within the vertical space
|
|
84
|
-
let y = pad;
|
|
85
|
-
if (value === "center") y = (h - barH) / 2;
|
|
86
|
-
else if (value === "bottom") y = h - pad - barH;
|
|
87
|
-
|
|
88
|
-
return (
|
|
89
|
-
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} fill="none">
|
|
90
|
-
{/* Container outline */}
|
|
91
|
-
<rect
|
|
92
|
-
x={pad - 0.5}
|
|
93
|
-
y={pad - 0.5}
|
|
94
|
-
width={barW + 1}
|
|
95
|
-
height={h - pad * 2 + 1}
|
|
96
|
-
rx={1.5}
|
|
97
|
-
stroke="currentColor"
|
|
98
|
-
strokeWidth={0.8}
|
|
99
|
-
opacity={0.3}
|
|
100
|
-
fill="none"
|
|
101
|
-
/>
|
|
102
|
-
{/* Bar at position */}
|
|
103
|
-
<rect
|
|
104
|
-
x={pad + 1}
|
|
105
|
-
y={y}
|
|
106
|
-
width={barW - 2}
|
|
107
|
-
height={barH}
|
|
108
|
-
rx={1}
|
|
109
|
-
fill="currentColor"
|
|
110
|
-
/>
|
|
111
|
-
</svg>
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function AlignmentButtons<T extends string>({
|
|
116
|
-
options,
|
|
117
|
-
value,
|
|
118
|
-
onChange,
|
|
119
|
-
renderIcon,
|
|
120
|
-
}: {
|
|
121
|
-
options: { value: T; label: string }[];
|
|
122
|
-
value: T;
|
|
123
|
-
onChange: (v: T) => void;
|
|
124
|
-
renderIcon: (v: T) => React.ReactNode;
|
|
125
|
-
}) {
|
|
126
|
-
return (
|
|
127
|
-
<div className="flex gap-1">
|
|
128
|
-
{options.map((opt) => {
|
|
129
|
-
const isActive = value === opt.value;
|
|
130
|
-
return (
|
|
131
|
-
<button
|
|
132
|
-
key={opt.value}
|
|
133
|
-
aria-label={opt.label}
|
|
134
|
-
onClick={() => onChange(opt.value)}
|
|
135
|
-
className={`group/bb relative flex items-center justify-center w-[34px] h-[30px] rounded-md border transition-all ${
|
|
136
|
-
isActive
|
|
137
|
-
? "bg-[#3580f9]/10 border-[#3580f9]/30 text-[#3580f9]"
|
|
138
|
-
: "bg-[#f5f5f5] border-transparent text-neutral-400 hover:bg-[#efefef] hover:text-neutral-600"
|
|
139
|
-
}`}
|
|
140
|
-
>
|
|
141
|
-
{renderIcon(opt.value)}
|
|
142
|
-
<BubbleTooltip>{opt.label}</BubbleTooltip>
|
|
143
|
-
</button>
|
|
144
|
-
);
|
|
145
|
-
})}
|
|
146
|
-
</div>
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// ── Section title icons (centralized colored icons — Session 163) ──
|
|
151
|
-
import {
|
|
152
|
-
AlignmentIcon,
|
|
153
|
-
SpacingIcon,
|
|
154
|
-
OffsetIcon,
|
|
155
|
-
BackgroundIcon,
|
|
156
|
-
BorderIcon,
|
|
157
|
-
} from "../editors/section-icons";
|
|
158
|
-
|
|
159
|
-
// ── Override indicator badge ──
|
|
160
|
-
|
|
161
|
-
function OverrideBadge({
|
|
162
|
-
block,
|
|
163
|
-
viewport,
|
|
164
|
-
properties,
|
|
165
|
-
onReset,
|
|
166
|
-
}: {
|
|
167
|
-
block: ContentBlock;
|
|
168
|
-
viewport: string;
|
|
169
|
-
properties: (keyof BlockLayout)[];
|
|
170
|
-
onReset: () => void;
|
|
171
|
-
}) {
|
|
172
|
-
if (viewport === "desktop") return null;
|
|
173
|
-
const hasAny = properties.some((p) => hasBlockLayoutOverride(block, viewport as "tablet" | "phone", p));
|
|
174
|
-
if (hasAny) {
|
|
175
|
-
return (
|
|
176
|
-
<div className="flex items-center gap-2 mt-1">
|
|
177
|
-
<span className="text-[9px] text-[#3580f9]">overridden</span>
|
|
178
|
-
<button
|
|
179
|
-
onClick={onReset}
|
|
180
|
-
className="text-[10px] text-neutral-400 hover:text-[var(--admin-error)] transition-colors"
|
|
181
|
-
>
|
|
182
|
-
Reset
|
|
183
|
-
</button>
|
|
184
|
-
</div>
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
return <p className="text-[9px] text-neutral-300 italic mt-1">inherited</p>;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// ── Main component ──
|
|
191
|
-
|
|
192
|
-
export function BlockLayoutTab({ block }: { block: ContentBlock }) {
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
{
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
<option value="
|
|
398
|
-
<option value="
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
<option value="repeat
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
<option value="
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
<option value="
|
|
480
|
-
<option value="
|
|
481
|
-
<option value="
|
|
482
|
-
<option value="
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BlockLayoutTab — Per-block layout styling.
|
|
5
|
+
*
|
|
6
|
+
* Section order: Alignment → Spacing → Offset → Background → Border.
|
|
7
|
+
* Each section has a small visual icon next to its title.
|
|
8
|
+
*
|
|
9
|
+
* Session 78: Added Alignment section with visual icon buttons.
|
|
10
|
+
* Reordered sections. Added section title icons.
|
|
11
|
+
* Session 82: Made viewport-aware with responsive override support.
|
|
12
|
+
* Every property is now editable per viewport (desktop/tablet/phone).
|
|
13
|
+
* Uses getBlockLayoutValue/setBlockLayoutOverride from responsive-helpers.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useBuilderStore } from "../../../lib/builder/store";
|
|
17
|
+
import type { ContentBlock, BlockLayout } from "../../../lib/sanity/types";
|
|
18
|
+
import {
|
|
19
|
+
SettingsField,
|
|
20
|
+
SettingsSection,
|
|
21
|
+
SELECT_CLASS,
|
|
22
|
+
AssetPathInput,
|
|
23
|
+
} from "../editors/shared";
|
|
24
|
+
import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
|
|
25
|
+
import { serializeColorField, parseColorField, isGradient } from "../../../lib/color-utils";
|
|
26
|
+
import { TRBLInputs } from "./TRBLInputs";
|
|
27
|
+
import { BubbleTooltip } from "../BubbleIcons";
|
|
28
|
+
import {
|
|
29
|
+
getBlockLayoutValue,
|
|
30
|
+
hasBlockLayoutOverride,
|
|
31
|
+
setBlockLayoutOverride,
|
|
32
|
+
} from "./responsive-helpers";
|
|
33
|
+
|
|
34
|
+
// ── Alignment visual icon buttons ──
|
|
35
|
+
|
|
36
|
+
type AlignH = "left" | "center" | "right";
|
|
37
|
+
type AlignV = "top" | "center" | "bottom";
|
|
38
|
+
|
|
39
|
+
function HAlignIcon({ value, size = 18 }: { value: AlignH; size?: number }) {
|
|
40
|
+
// Horizontal lines aligned left/center/right inside a box
|
|
41
|
+
const w = size;
|
|
42
|
+
const h = size;
|
|
43
|
+
const pad = 3;
|
|
44
|
+
const barH = 2.5;
|
|
45
|
+
const gap = 1.5;
|
|
46
|
+
const maxW = w - pad * 2;
|
|
47
|
+
|
|
48
|
+
const bars = [
|
|
49
|
+
{ w: maxW * 0.9, y: pad + 1 },
|
|
50
|
+
{ w: maxW * 0.55, y: pad + 1 + barH + gap },
|
|
51
|
+
{ w: maxW * 0.75, y: pad + 1 + (barH + gap) * 2 },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} fill="none">
|
|
56
|
+
{bars.map((bar, i) => {
|
|
57
|
+
let x = pad;
|
|
58
|
+
if (value === "center") x = (w - bar.w) / 2;
|
|
59
|
+
else if (value === "right") x = w - pad - bar.w;
|
|
60
|
+
return (
|
|
61
|
+
<rect
|
|
62
|
+
key={i}
|
|
63
|
+
x={x}
|
|
64
|
+
y={bar.y}
|
|
65
|
+
width={bar.w}
|
|
66
|
+
height={barH}
|
|
67
|
+
rx={1}
|
|
68
|
+
fill="currentColor"
|
|
69
|
+
/>
|
|
70
|
+
);
|
|
71
|
+
})}
|
|
72
|
+
</svg>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function VAlignIcon({ value, size = 18 }: { value: AlignV; size?: number }) {
|
|
77
|
+
const w = size;
|
|
78
|
+
const h = size;
|
|
79
|
+
const pad = 3;
|
|
80
|
+
const barW = w - pad * 2;
|
|
81
|
+
const barH = 3;
|
|
82
|
+
|
|
83
|
+
// Position of a single bar within the vertical space
|
|
84
|
+
let y = pad;
|
|
85
|
+
if (value === "center") y = (h - barH) / 2;
|
|
86
|
+
else if (value === "bottom") y = h - pad - barH;
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} fill="none">
|
|
90
|
+
{/* Container outline */}
|
|
91
|
+
<rect
|
|
92
|
+
x={pad - 0.5}
|
|
93
|
+
y={pad - 0.5}
|
|
94
|
+
width={barW + 1}
|
|
95
|
+
height={h - pad * 2 + 1}
|
|
96
|
+
rx={1.5}
|
|
97
|
+
stroke="currentColor"
|
|
98
|
+
strokeWidth={0.8}
|
|
99
|
+
opacity={0.3}
|
|
100
|
+
fill="none"
|
|
101
|
+
/>
|
|
102
|
+
{/* Bar at position */}
|
|
103
|
+
<rect
|
|
104
|
+
x={pad + 1}
|
|
105
|
+
y={y}
|
|
106
|
+
width={barW - 2}
|
|
107
|
+
height={barH}
|
|
108
|
+
rx={1}
|
|
109
|
+
fill="currentColor"
|
|
110
|
+
/>
|
|
111
|
+
</svg>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function AlignmentButtons<T extends string>({
|
|
116
|
+
options,
|
|
117
|
+
value,
|
|
118
|
+
onChange,
|
|
119
|
+
renderIcon,
|
|
120
|
+
}: {
|
|
121
|
+
options: { value: T; label: string }[];
|
|
122
|
+
value: T;
|
|
123
|
+
onChange: (v: T) => void;
|
|
124
|
+
renderIcon: (v: T) => React.ReactNode;
|
|
125
|
+
}) {
|
|
126
|
+
return (
|
|
127
|
+
<div className="flex gap-1">
|
|
128
|
+
{options.map((opt) => {
|
|
129
|
+
const isActive = value === opt.value;
|
|
130
|
+
return (
|
|
131
|
+
<button
|
|
132
|
+
key={opt.value}
|
|
133
|
+
aria-label={opt.label}
|
|
134
|
+
onClick={() => onChange(opt.value)}
|
|
135
|
+
className={`group/bb relative flex items-center justify-center w-[34px] h-[30px] rounded-md border transition-all ${
|
|
136
|
+
isActive
|
|
137
|
+
? "bg-[#3580f9]/10 border-[#3580f9]/30 text-[#3580f9]"
|
|
138
|
+
: "bg-[#f5f5f5] border-transparent text-neutral-400 hover:bg-[#efefef] hover:text-neutral-600"
|
|
139
|
+
}`}
|
|
140
|
+
>
|
|
141
|
+
{renderIcon(opt.value)}
|
|
142
|
+
<BubbleTooltip>{opt.label}</BubbleTooltip>
|
|
143
|
+
</button>
|
|
144
|
+
);
|
|
145
|
+
})}
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Section title icons (centralized colored icons — Session 163) ──
|
|
151
|
+
import {
|
|
152
|
+
AlignmentIcon,
|
|
153
|
+
SpacingIcon,
|
|
154
|
+
OffsetIcon,
|
|
155
|
+
BackgroundIcon,
|
|
156
|
+
BorderIcon,
|
|
157
|
+
} from "../editors/section-icons";
|
|
158
|
+
|
|
159
|
+
// ── Override indicator badge ──
|
|
160
|
+
|
|
161
|
+
function OverrideBadge({
|
|
162
|
+
block,
|
|
163
|
+
viewport,
|
|
164
|
+
properties,
|
|
165
|
+
onReset,
|
|
166
|
+
}: {
|
|
167
|
+
block: ContentBlock;
|
|
168
|
+
viewport: string;
|
|
169
|
+
properties: (keyof BlockLayout)[];
|
|
170
|
+
onReset: () => void;
|
|
171
|
+
}) {
|
|
172
|
+
if (viewport === "desktop") return null;
|
|
173
|
+
const hasAny = properties.some((p) => hasBlockLayoutOverride(block, viewport as "tablet" | "phone", p));
|
|
174
|
+
if (hasAny) {
|
|
175
|
+
return (
|
|
176
|
+
<div className="flex items-center gap-2 mt-1">
|
|
177
|
+
<span className="text-[9px] text-[#3580f9]">overridden</span>
|
|
178
|
+
<button
|
|
179
|
+
onClick={onReset}
|
|
180
|
+
className="text-[10px] text-neutral-400 hover:text-[var(--admin-error)] transition-colors"
|
|
181
|
+
>
|
|
182
|
+
Reset
|
|
183
|
+
</button>
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return <p className="text-[9px] text-neutral-300 italic mt-1">inherited</p>;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Main component ──
|
|
191
|
+
|
|
192
|
+
export function BlockLayoutTab({ block }: { block: ContentBlock }) {
|
|
193
|
+
const activeViewport = useBuilderStore((s) => s.activeViewport);
|
|
194
|
+
const updateBlock = useBuilderStore((s) => s.updateBlock);
|
|
195
|
+
const _pushSnapshot = useBuilderStore((s) => s._pushSnapshot);
|
|
196
|
+
const setColorPickerPreview = useBuilderStore((s) => s.setColorPickerPreview);
|
|
197
|
+
const clearColorPickerPreview = useBuilderStore((s) => s.clearColorPickerPreview);
|
|
198
|
+
const paletteSwatches = usePaletteSwatches();
|
|
199
|
+
|
|
200
|
+
// Live preview callbacks (Phase 4)
|
|
201
|
+
const handleBgPreview = (val: import("../../../lib/sanity/types").ColorField) => {
|
|
202
|
+
setColorPickerPreview({ blockKey: block._key, field: "background_color", value: val });
|
|
203
|
+
};
|
|
204
|
+
const handleBorderPreview = (val: import("../../../lib/sanity/types").ColorField) => {
|
|
205
|
+
setColorPickerPreview({ blockKey: block._key, field: "border_color", value: val });
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/** Update a block layout property, viewport-aware */
|
|
209
|
+
const updateLayout = (property: keyof BlockLayout, value: unknown) => {
|
|
210
|
+
const updates = setBlockLayoutOverride(block, activeViewport, property, value);
|
|
211
|
+
updateBlock(block._key, updates);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/** Reset all layout overrides for a group of properties */
|
|
215
|
+
const resetGroup = (properties: (keyof BlockLayout)[]) => {
|
|
216
|
+
_pushSnapshot();
|
|
217
|
+
// Accumulate resets by setting each property to undefined
|
|
218
|
+
let accumulated = block;
|
|
219
|
+
for (const prop of properties) {
|
|
220
|
+
const u = setBlockLayoutOverride(accumulated, activeViewport, prop, undefined);
|
|
221
|
+
accumulated = { ...accumulated, ...u } as ContentBlock;
|
|
222
|
+
}
|
|
223
|
+
const responsive = (accumulated as unknown as Record<string, unknown>).responsive;
|
|
224
|
+
updateBlock(block._key, { responsive } as unknown as Partial<ContentBlock>);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// Effective values per viewport
|
|
228
|
+
const bgOpacity = getBlockLayoutValue<number>(block, activeViewport, "background_opacity", 100);
|
|
229
|
+
const bgIsGradient = isGradient(parseColorField(getBlockLayoutValue<string>(block, activeViewport, "background_color", "")));
|
|
230
|
+
const alignH: AlignH = getBlockLayoutValue<string>(block, activeViewport, "align_h", "left") as AlignH;
|
|
231
|
+
const alignV: AlignV = getBlockLayoutValue<string>(block, activeViewport, "align_v", "top") as AlignV;
|
|
232
|
+
|
|
233
|
+
const viewportLabel = activeViewport !== "desktop"
|
|
234
|
+
? activeViewport === "tablet" ? "Tablet" : "Phone"
|
|
235
|
+
: null;
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<>
|
|
239
|
+
{viewportLabel && (
|
|
240
|
+
<div className="px-4 pt-3">
|
|
241
|
+
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#3580f9]/8 border border-[#3580f9]/15">
|
|
242
|
+
<span className="text-[11px] font-medium text-[#3580f9]">
|
|
243
|
+
Editing {viewportLabel} overrides
|
|
244
|
+
</span>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
)}
|
|
248
|
+
|
|
249
|
+
{/* Alignment */}
|
|
250
|
+
<SettingsSection title="Alignment" defaultOpen icon={<AlignmentIcon />}>
|
|
251
|
+
<SettingsField label="Horizontal">
|
|
252
|
+
<AlignmentButtons<AlignH>
|
|
253
|
+
options={[
|
|
254
|
+
{ value: "left", label: "Left" },
|
|
255
|
+
{ value: "center", label: "Center" },
|
|
256
|
+
{ value: "right", label: "Right" },
|
|
257
|
+
]}
|
|
258
|
+
value={alignH}
|
|
259
|
+
onChange={(v) => {
|
|
260
|
+
_pushSnapshot();
|
|
261
|
+
updateLayout("align_h", v);
|
|
262
|
+
}}
|
|
263
|
+
renderIcon={(v) => <HAlignIcon value={v} />}
|
|
264
|
+
/>
|
|
265
|
+
</SettingsField>
|
|
266
|
+
|
|
267
|
+
<SettingsField label="Vertical">
|
|
268
|
+
<AlignmentButtons<AlignV>
|
|
269
|
+
options={[
|
|
270
|
+
{ value: "top", label: "Top" },
|
|
271
|
+
{ value: "center", label: "Center" },
|
|
272
|
+
{ value: "bottom", label: "Bottom" },
|
|
273
|
+
]}
|
|
274
|
+
value={alignV}
|
|
275
|
+
onChange={(v) => {
|
|
276
|
+
_pushSnapshot();
|
|
277
|
+
updateLayout("align_v", v);
|
|
278
|
+
}}
|
|
279
|
+
renderIcon={(v) => <VAlignIcon value={v} />}
|
|
280
|
+
/>
|
|
281
|
+
</SettingsField>
|
|
282
|
+
|
|
283
|
+
<OverrideBadge
|
|
284
|
+
block={block}
|
|
285
|
+
viewport={activeViewport}
|
|
286
|
+
properties={["align_h", "align_v"]}
|
|
287
|
+
onReset={() => resetGroup(["align_h", "align_v"])}
|
|
288
|
+
/>
|
|
289
|
+
</SettingsSection>
|
|
290
|
+
|
|
291
|
+
{/* Spacing (Padding) */}
|
|
292
|
+
<SettingsSection title="Spacing" defaultOpen icon={<SpacingIcon />}>
|
|
293
|
+
<TRBLInputs
|
|
294
|
+
top={getBlockLayoutValue<string>(block, activeViewport, "spacing_top", "0")}
|
|
295
|
+
right={getBlockLayoutValue<string>(block, activeViewport, "spacing_right", "0")}
|
|
296
|
+
bottom={getBlockLayoutValue<string>(block, activeViewport, "spacing_bottom", "0")}
|
|
297
|
+
left={getBlockLayoutValue<string>(block, activeViewport, "spacing_left", "0")}
|
|
298
|
+
onChange={(field, value) => {
|
|
299
|
+
updateLayout(`spacing_${field}` as keyof BlockLayout, value);
|
|
300
|
+
}}
|
|
301
|
+
/>
|
|
302
|
+
<OverrideBadge
|
|
303
|
+
block={block}
|
|
304
|
+
viewport={activeViewport}
|
|
305
|
+
properties={["spacing_top", "spacing_right", "spacing_bottom", "spacing_left"]}
|
|
306
|
+
onReset={() => resetGroup(["spacing_top", "spacing_right", "spacing_bottom", "spacing_left"])}
|
|
307
|
+
/>
|
|
308
|
+
</SettingsSection>
|
|
309
|
+
|
|
310
|
+
{/* Offset (Margin) */}
|
|
311
|
+
<SettingsSection title="Offset" icon={<OffsetIcon />}>
|
|
312
|
+
<TRBLInputs
|
|
313
|
+
top={getBlockLayoutValue<string>(block, activeViewport, "offset_top", "0")}
|
|
314
|
+
right={getBlockLayoutValue<string>(block, activeViewport, "offset_right", "0")}
|
|
315
|
+
bottom={getBlockLayoutValue<string>(block, activeViewport, "offset_bottom", "0")}
|
|
316
|
+
left={getBlockLayoutValue<string>(block, activeViewport, "offset_left", "0")}
|
|
317
|
+
onChange={(field, value) => {
|
|
318
|
+
updateLayout(`offset_${field}` as keyof BlockLayout, value);
|
|
319
|
+
}}
|
|
320
|
+
/>
|
|
321
|
+
<OverrideBadge
|
|
322
|
+
block={block}
|
|
323
|
+
viewport={activeViewport}
|
|
324
|
+
properties={["offset_top", "offset_right", "offset_bottom", "offset_left"]}
|
|
325
|
+
onReset={() => resetGroup(["offset_top", "offset_right", "offset_bottom", "offset_left"])}
|
|
326
|
+
/>
|
|
327
|
+
</SettingsSection>
|
|
328
|
+
|
|
329
|
+
{/* Background */}
|
|
330
|
+
<SettingsSection title="Background" defaultOpen icon={<BackgroundIcon />}>
|
|
331
|
+
<SettingsField label="Color">
|
|
332
|
+
<ColorSwatchPicker
|
|
333
|
+
value={parseColorField(getBlockLayoutValue<string>(block, activeViewport, "background_color", ""))}
|
|
334
|
+
onChange={(val) => {
|
|
335
|
+
_pushSnapshot();
|
|
336
|
+
clearColorPickerPreview();
|
|
337
|
+
updateLayout("background_color", serializeColorField(val));
|
|
338
|
+
}}
|
|
339
|
+
swatches={paletteSwatches}
|
|
340
|
+
allowGradients
|
|
341
|
+
onPreview={handleBgPreview}
|
|
342
|
+
/>
|
|
343
|
+
</SettingsField>
|
|
344
|
+
|
|
345
|
+
<SettingsField label="Opacity">
|
|
346
|
+
<div className="flex items-center gap-2">
|
|
347
|
+
<input
|
|
348
|
+
type="range"
|
|
349
|
+
min={0}
|
|
350
|
+
max={100}
|
|
351
|
+
value={bgOpacity}
|
|
352
|
+
onChange={(e) => updateLayout("background_opacity", parseInt(e.target.value))}
|
|
353
|
+
className={`flex-1 accent-[#3580f9] ${bgIsGradient ? "opacity-40 pointer-events-none" : ""}`}
|
|
354
|
+
disabled={bgIsGradient}
|
|
355
|
+
/>
|
|
356
|
+
<span className="text-xs text-neutral-900 w-10 text-right">
|
|
357
|
+
{bgOpacity}%
|
|
358
|
+
</span>
|
|
359
|
+
</div>
|
|
360
|
+
{bgIsGradient && (
|
|
361
|
+
<p className="text-[9px] text-neutral-400 italic mt-1">
|
|
362
|
+
Opacity is controlled per stop in gradient mode
|
|
363
|
+
</p>
|
|
364
|
+
)}
|
|
365
|
+
</SettingsField>
|
|
366
|
+
|
|
367
|
+
<SettingsField label="Image">
|
|
368
|
+
<AssetPathInput
|
|
369
|
+
value={getBlockLayoutValue<string>(block, activeViewport, "background_image", "")}
|
|
370
|
+
onFocus={() => _pushSnapshot()}
|
|
371
|
+
onChange={(v) => updateLayout("background_image", v)}
|
|
372
|
+
placeholder="path/to/image.jpg"
|
|
373
|
+
filterType="image"
|
|
374
|
+
/>
|
|
375
|
+
</SettingsField>
|
|
376
|
+
|
|
377
|
+
{getBlockLayoutValue<string>(block, activeViewport, "background_image", "") && (
|
|
378
|
+
<>
|
|
379
|
+
<SettingsField label="Size">
|
|
380
|
+
<select
|
|
381
|
+
value={getBlockLayoutValue<string>(block, activeViewport, "background_size", "cover")}
|
|
382
|
+
onChange={(e) => updateLayout("background_size", e.target.value)}
|
|
383
|
+
className={SELECT_CLASS}
|
|
384
|
+
>
|
|
385
|
+
<option value="cover">Cover</option>
|
|
386
|
+
<option value="contain">Contain</option>
|
|
387
|
+
<option value="auto">Auto</option>
|
|
388
|
+
</select>
|
|
389
|
+
</SettingsField>
|
|
390
|
+
|
|
391
|
+
<SettingsField label="Position">
|
|
392
|
+
<select
|
|
393
|
+
value={getBlockLayoutValue<string>(block, activeViewport, "background_position", "center center")}
|
|
394
|
+
onChange={(e) => updateLayout("background_position", e.target.value)}
|
|
395
|
+
className={SELECT_CLASS}
|
|
396
|
+
>
|
|
397
|
+
<option value="center center">Center</option>
|
|
398
|
+
<option value="top center">Top</option>
|
|
399
|
+
<option value="bottom center">Bottom</option>
|
|
400
|
+
<option value="left center">Left</option>
|
|
401
|
+
<option value="right center">Right</option>
|
|
402
|
+
</select>
|
|
403
|
+
</SettingsField>
|
|
404
|
+
|
|
405
|
+
<SettingsField label="Repeat">
|
|
406
|
+
<select
|
|
407
|
+
value={getBlockLayoutValue<string>(block, activeViewport, "background_repeat", "no-repeat")}
|
|
408
|
+
onChange={(e) => updateLayout("background_repeat", e.target.value)}
|
|
409
|
+
className={SELECT_CLASS}
|
|
410
|
+
>
|
|
411
|
+
<option value="no-repeat">No Repeat</option>
|
|
412
|
+
<option value="repeat">Repeat</option>
|
|
413
|
+
<option value="repeat-x">Repeat X</option>
|
|
414
|
+
<option value="repeat-y">Repeat Y</option>
|
|
415
|
+
</select>
|
|
416
|
+
</SettingsField>
|
|
417
|
+
</>
|
|
418
|
+
)}
|
|
419
|
+
|
|
420
|
+
<OverrideBadge
|
|
421
|
+
block={block}
|
|
422
|
+
viewport={activeViewport}
|
|
423
|
+
properties={["background_color", "background_opacity", "background_image", "background_size", "background_position", "background_repeat"]}
|
|
424
|
+
onReset={() => resetGroup(["background_color", "background_opacity", "background_image", "background_size", "background_position", "background_repeat"])}
|
|
425
|
+
/>
|
|
426
|
+
</SettingsSection>
|
|
427
|
+
|
|
428
|
+
{/* Border */}
|
|
429
|
+
<SettingsSection title="Border" icon={<BorderIcon />}>
|
|
430
|
+
<SettingsField label="Color">
|
|
431
|
+
<ColorSwatchPicker
|
|
432
|
+
value={parseColorField(getBlockLayoutValue<string>(block, activeViewport, "border_color", ""))}
|
|
433
|
+
onChange={(val) => {
|
|
434
|
+
_pushSnapshot();
|
|
435
|
+
clearColorPickerPreview();
|
|
436
|
+
updateLayout("border_color", serializeColorField(val));
|
|
437
|
+
}}
|
|
438
|
+
swatches={paletteSwatches}
|
|
439
|
+
allowGradients
|
|
440
|
+
onPreview={handleBorderPreview}
|
|
441
|
+
/>
|
|
442
|
+
</SettingsField>
|
|
443
|
+
|
|
444
|
+
<SettingsField label="Width">
|
|
445
|
+
<div className="flex items-center gap-2">
|
|
446
|
+
<input
|
|
447
|
+
type="range"
|
|
448
|
+
min={0}
|
|
449
|
+
max={20}
|
|
450
|
+
value={parseInt(getBlockLayoutValue<string>(block, activeViewport, "border_width", "0"))}
|
|
451
|
+
onChange={(e) => updateLayout("border_width", e.target.value)}
|
|
452
|
+
className="flex-1 accent-[#3580f9]"
|
|
453
|
+
/>
|
|
454
|
+
<span className="text-xs text-neutral-900 w-10 text-right">
|
|
455
|
+
{getBlockLayoutValue<string>(block, activeViewport, "border_width", "0")}px
|
|
456
|
+
</span>
|
|
457
|
+
</div>
|
|
458
|
+
</SettingsField>
|
|
459
|
+
|
|
460
|
+
<SettingsField label="Style">
|
|
461
|
+
<select
|
|
462
|
+
value={getBlockLayoutValue<string>(block, activeViewport, "border_style", "none")}
|
|
463
|
+
onChange={(e) => updateLayout("border_style", e.target.value)}
|
|
464
|
+
className={SELECT_CLASS}
|
|
465
|
+
>
|
|
466
|
+
<option value="none">None</option>
|
|
467
|
+
<option value="solid">Solid</option>
|
|
468
|
+
<option value="dashed">Dashed</option>
|
|
469
|
+
<option value="dotted">Dotted</option>
|
|
470
|
+
</select>
|
|
471
|
+
</SettingsField>
|
|
472
|
+
|
|
473
|
+
<SettingsField label="Sides">
|
|
474
|
+
<select
|
|
475
|
+
value={getBlockLayoutValue<string>(block, activeViewport, "border_sides", "all")}
|
|
476
|
+
onChange={(e) => updateLayout("border_sides", e.target.value)}
|
|
477
|
+
className={SELECT_CLASS}
|
|
478
|
+
>
|
|
479
|
+
<option value="all">All</option>
|
|
480
|
+
<option value="top">Top</option>
|
|
481
|
+
<option value="right">Right</option>
|
|
482
|
+
<option value="bottom">Bottom</option>
|
|
483
|
+
<option value="left">Left</option>
|
|
484
|
+
<option value="top-bottom">Top & Bottom</option>
|
|
485
|
+
<option value="left-right">Left & Right</option>
|
|
486
|
+
</select>
|
|
487
|
+
</SettingsField>
|
|
488
|
+
|
|
489
|
+
<SettingsField label="Radius">
|
|
490
|
+
<div className="flex items-center gap-2">
|
|
491
|
+
<input
|
|
492
|
+
type="range"
|
|
493
|
+
min={0}
|
|
494
|
+
max={50}
|
|
495
|
+
value={parseInt(getBlockLayoutValue<string>(block, activeViewport, "border_radius", "0"))}
|
|
496
|
+
onChange={(e) => updateLayout("border_radius", e.target.value)}
|
|
497
|
+
className="flex-1 accent-[#3580f9]"
|
|
498
|
+
/>
|
|
499
|
+
<span className="text-xs text-neutral-900 w-10 text-right">
|
|
500
|
+
{getBlockLayoutValue<string>(block, activeViewport, "border_radius", "0")}px
|
|
501
|
+
</span>
|
|
502
|
+
</div>
|
|
503
|
+
</SettingsField>
|
|
504
|
+
|
|
505
|
+
<OverrideBadge
|
|
506
|
+
block={block}
|
|
507
|
+
viewport={activeViewport}
|
|
508
|
+
properties={["border_color", "border_width", "border_style", "border_sides", "border_radius"]}
|
|
509
|
+
onReset={() => resetGroup(["border_color", "border_width", "border_style", "border_sides", "border_radius"])}
|
|
510
|
+
/>
|
|
511
|
+
</SettingsSection>
|
|
512
|
+
</>
|
|
513
|
+
);
|
|
514
|
+
}
|