@morphika/andami 0.5.1 → 0.5.3

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.
Files changed (147) hide show
  1. package/README.md +27 -2
  2. package/app/admin/assets/page.tsx +6 -6
  3. package/app/admin/database/page.tsx +302 -302
  4. package/app/admin/error.tsx +53 -53
  5. package/app/admin/layout.tsx +332 -320
  6. package/app/admin/navigation/page.tsx +255 -255
  7. package/app/admin/pages/[slug]/page.tsx +44 -27
  8. package/app/admin/pages/page.tsx +24 -19
  9. package/app/admin/projects/page.tsx +30 -21
  10. package/app/admin/setup/page.tsx +1 -1
  11. package/app/admin/styles/page.tsx +1 -1
  12. package/app/api/admin/assets/register/route.ts +51 -14
  13. package/app/api/admin/assets/registry/route.ts +4 -1
  14. package/app/api/admin/assets/relink/confirm/route.ts +4 -1
  15. package/app/api/admin/assets/relink/route.ts +4 -1
  16. package/app/api/admin/assets/scan/route.ts +4 -1
  17. package/app/api/admin/backups/restore-data/route.ts +4 -1
  18. package/app/api/admin/r2/connect/route.ts +4 -1
  19. package/app/api/admin/r2/delete/route.ts +4 -1
  20. package/app/api/admin/r2/rename/route.ts +4 -1
  21. package/app/api/admin/r2/upload-url/route.ts +4 -1
  22. package/app/api/admin/revalidate/route.ts +4 -1
  23. package/app/api/admin/storage/switch/route.ts +4 -1
  24. package/app/api/custom-sections/[id]/route.ts +5 -6
  25. package/components/admin/MetadataEditor.tsx +6 -6
  26. package/components/admin/PublishToggle.tsx +2 -2
  27. package/components/admin/nav-builder/NavBuilder.tsx +1 -1
  28. package/components/admin/nav-builder/NavBuilderGrid.tsx +3 -3
  29. package/components/admin/nav-builder/NavGridCell.tsx +48 -48
  30. package/components/admin/nav-builder/NavGridItem.tsx +8 -6
  31. package/components/admin/nav-builder/NavItemSettings.tsx +331 -331
  32. package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -102
  33. package/components/admin/nav-builder/NavLivePreview.tsx +1 -1
  34. package/components/admin/nav-builder/NavMobileLivePreview.tsx +226 -226
  35. package/components/admin/nav-builder/NavMobileSettings.tsx +242 -242
  36. package/components/admin/nav-builder/NavSettingsFields.tsx +518 -514
  37. package/components/admin/setup-wizard/BrandingStep.tsx +3 -3
  38. package/components/admin/setup-wizard/DatabaseStep.tsx +2 -2
  39. package/components/admin/setup-wizard/DoneStep.tsx +1 -1
  40. package/components/admin/setup-wizard/SetupWizard.tsx +4 -4
  41. package/components/admin/setup-wizard/StorageStep.tsx +2 -2
  42. package/components/admin/setup-wizard/WelcomeStep.tsx +2 -2
  43. package/components/admin/styles/ColorsEditor.tsx +9 -8
  44. package/components/admin/styles/FontsEditor.tsx +9 -7
  45. package/components/admin/styles/GridLayoutEditor.tsx +9 -9
  46. package/components/admin/styles/LinksButtonsEditor.tsx +5 -5
  47. package/components/admin/styles/TypographyEditor.tsx +6 -6
  48. package/components/admin/styles/shared.tsx +68 -68
  49. package/components/blocks/AudioBlockRenderer.tsx +286 -286
  50. package/components/blocks/CoverSectionRenderer.tsx +7 -1
  51. package/components/blocks/MarqueeBlockRenderer.tsx +316 -0
  52. package/components/blocks/ProjectCarouselBlockRenderer.tsx +1 -1
  53. package/components/blocks/SectionV2Renderer.tsx +8 -1
  54. package/components/builder/BlockCardIcons.tsx +316 -316
  55. package/components/builder/BlockTypePicker.tsx +1 -1
  56. package/components/builder/BubbleIcons.tsx +104 -0
  57. package/components/builder/BuilderCanvas.tsx +2 -0
  58. package/components/builder/CanvasMinimap.tsx +66 -49
  59. package/components/builder/CanvasToolbar.tsx +31 -41
  60. package/components/builder/CoverSectionCanvas.tsx +363 -363
  61. package/components/builder/DeviceFrame.tsx +1 -1
  62. package/components/builder/DndWrapper.tsx +3 -3
  63. package/components/builder/InsertionLines.tsx +1 -1
  64. package/components/builder/SectionCardIcons.tsx +421 -320
  65. package/components/builder/SectionEditorBar.tsx +5 -3
  66. package/components/builder/SectionTypePicker.tsx +7 -5
  67. package/components/builder/SectionV2Canvas.tsx +1 -1
  68. package/components/builder/SectionV2Column.tsx +82 -68
  69. package/components/builder/SettingsPanel.tsx +21 -17
  70. package/components/builder/SortableBlock.tsx +93 -73
  71. package/components/builder/SortableRow.tsx +33 -35
  72. package/components/builder/VirtualAssetGrid.tsx +10 -4
  73. package/components/builder/asset-browser/R2BrowserContent.tsx +18 -14
  74. package/components/builder/blockStyles.tsx +192 -185
  75. package/components/builder/color-picker/AlphaSlider.tsx +141 -141
  76. package/components/builder/color-picker/ColorInputs.tsx +105 -105
  77. package/components/builder/color-picker/EyedropperButton.tsx +75 -74
  78. package/components/builder/color-picker/HueSlider.tsx +124 -124
  79. package/components/builder/color-picker/SaturationCanvas.tsx +142 -142
  80. package/components/builder/color-picker/SwatchBar.tsx +98 -93
  81. package/components/builder/color-picker/UnifiedColorPicker.tsx +11 -6
  82. package/components/builder/editors/AudioBlockEditor.tsx +242 -242
  83. package/components/builder/editors/BeforeAfterBlockEditor.tsx +360 -360
  84. package/components/builder/editors/ButtonBlockEditor.tsx +4 -4
  85. package/components/builder/editors/EnterAnimationPicker.tsx +2 -2
  86. package/components/builder/editors/HoverEffectPicker.tsx +2 -2
  87. package/components/builder/editors/ImageBlockEditor.tsx +2 -2
  88. package/components/builder/editors/ImageGridBlockEditor.tsx +8 -6
  89. package/components/builder/editors/MarqueeBlockEditor.tsx +622 -0
  90. package/components/builder/editors/ProjectCarouselBlockEditor.tsx +443 -443
  91. package/components/builder/editors/ProjectGridEditor.tsx +21 -16
  92. package/components/builder/editors/SpacerBlockEditor.tsx +29 -27
  93. package/components/builder/editors/StaggerSettings.tsx +109 -109
  94. package/components/builder/editors/TextBlockEditor.tsx +22 -17
  95. package/components/builder/editors/TextStylePicker.tsx +1 -1
  96. package/components/builder/editors/VideoBlockEditor.tsx +2 -2
  97. package/components/builder/editors/index.ts +11 -10
  98. package/components/builder/editors/shared.tsx +10 -8
  99. package/components/builder/live-preview/LiveAudioPreview.tsx +120 -120
  100. package/components/builder/live-preview/LiveBeforeAfterPreview.tsx +1 -1
  101. package/components/builder/live-preview/LiveImageGridPreview.tsx +10 -2
  102. package/components/builder/live-preview/LiveImagePreview.tsx +4 -2
  103. package/components/builder/live-preview/LiveMarqueePreview.tsx +39 -0
  104. package/components/builder/live-preview/LiveProjectCarouselPreview.tsx +1 -1
  105. package/components/builder/live-preview/LiveVideoPreview.tsx +1 -1
  106. package/components/builder/live-preview/ProjectCardWrapper.tsx +293 -291
  107. package/components/builder/live-preview/RichTextBubbleMenu.tsx +10 -6
  108. package/components/builder/live-preview/shared.tsx +5 -2
  109. package/components/builder/settings-panel/AnimationTab.tsx +138 -138
  110. package/components/builder/settings-panel/BlockLayoutTab.tsx +11 -9
  111. package/components/builder/settings-panel/CardEntranceSection.tsx +114 -114
  112. package/components/builder/settings-panel/ColumnV2LayoutTab.tsx +242 -0
  113. package/components/builder/settings-panel/ColumnV2Settings.tsx +5 -5
  114. package/components/builder/settings-panel/CoverSectionLayoutTab.tsx +71 -71
  115. package/components/builder/settings-panel/CoverSectionSettings.tsx +337 -335
  116. package/components/builder/settings-panel/PageSettings.tsx +3 -3
  117. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +2 -2
  118. package/components/builder/settings-panel/SectionV2AnimationTab.tsx +4 -4
  119. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +356 -356
  120. package/components/builder/settings-panel/SectionV2Settings.tsx +25 -20
  121. package/components/builder/settings-panel/TRBLInputs.tsx +1 -1
  122. package/components/builder/settings-panel/index.ts +1 -0
  123. package/lib/animation/enter-types.ts +1 -0
  124. package/lib/animation/hover-effect-presets.ts +210 -210
  125. package/lib/animation/hover-effect-types.ts +1 -0
  126. package/lib/builder/block-registrations.ts +468 -417
  127. package/lib/builder/constants.ts +111 -111
  128. package/lib/builder/serializer/normalizers.ts +14 -0
  129. package/lib/builder/serializer/serializers.ts +27 -0
  130. package/lib/builder/store-sections.ts +23 -2
  131. package/lib/builder/types-slices.ts +428 -414
  132. package/lib/builder/types.ts +4 -1
  133. package/lib/config/index.ts +27 -27
  134. package/lib/sanity/queries.ts +48 -0
  135. package/lib/sanity/types.ts +112 -1
  136. package/lib/version.ts +1 -1
  137. package/package.json +7 -5
  138. package/sanity/schemas/blocks/audioBlock.ts +69 -69
  139. package/sanity/schemas/blocks/index.ts +12 -11
  140. package/sanity/schemas/blocks/marqueeBlock.ts +292 -0
  141. package/sanity/schemas/index.ts +120 -117
  142. package/sanity/schemas/objects/coverSection.ts +32 -0
  143. package/sanity/schemas/objects/parallaxSlide.ts +32 -0
  144. package/sanity/schemas/pageSectionV2.ts +32 -0
  145. package/styles/admin.css +85 -85
  146. package/styles/animations.css +237 -237
  147. package/styles/base.css +114 -114
@@ -1,286 +1,286 @@
1
- "use client";
2
-
3
- import { useCallback, useEffect, useRef, useState } from "react";
4
- import type { AudioBlock } from "../../lib/sanity/types";
5
- import { useAssetUrl } from "../../lib/contexts/AssetContext";
6
- import { handleImageRetry } from "../../lib/asset-retry";
7
-
8
- /**
9
- * AudioBlockRenderer — minimal custom audio player.
10
- *
11
- * Layout: optional cover art + (title / artist) + play button + progress bar + time.
12
- * Accent color applies to the play button background and progress fill.
13
- * Uses the HTML5 Audio element via a ref; progress + duration driven by
14
- * timeupdate / loadedmetadata events.
15
- */
16
-
17
- const widthStyleMap: Record<string, { width: string; margin?: string }> = {
18
- full: { width: "100%" },
19
- contained: { width: "75%", margin: "0 auto" },
20
- small: { width: "50%", margin: "0 auto" },
21
- };
22
-
23
- function formatTime(seconds: number): string {
24
- if (!isFinite(seconds) || seconds < 0) return "0:00";
25
- const m = Math.floor(seconds / 60);
26
- const s = Math.floor(seconds % 60);
27
- return `${m}:${s.toString().padStart(2, "0")}`;
28
- }
29
-
30
- export default function AudioBlockRenderer({ block }: { block: AudioBlock }) {
31
- const resolveAsset = useAssetUrl();
32
-
33
- const src = resolveAsset(block.asset_path);
34
- const coverSrc = block.cover_path ? resolveAsset(block.cover_path) : null;
35
- const accent = block.accent_color || "#4794E2";
36
-
37
- const isFill = block.width === "fill";
38
- const widthStyle = isFill ? {} : (widthStyleMap[block.width ?? "contained"] || widthStyleMap.contained);
39
-
40
- const rawRadius = block.border_radius ? String(block.border_radius).replace(/[a-z%]+$/i, "") : "";
41
- const borderRadius = rawRadius && !isNaN(Number(rawRadius)) ? `${rawRadius}px` : "12px";
42
-
43
- const autoplay = block.autoplay === true;
44
- const loop = block.loop === true;
45
- const initialMuted = block.muted === true;
46
-
47
- const audioRef = useRef<HTMLAudioElement | null>(null);
48
- const progressRef = useRef<HTMLDivElement | null>(null);
49
- const draggingRef = useRef(false);
50
-
51
- const [isPlaying, setIsPlaying] = useState<boolean>(false);
52
- const [currentTime, setCurrentTime] = useState<number>(0);
53
- const [duration, setDuration] = useState<number>(0);
54
- const [muted, setMuted] = useState<boolean>(initialMuted);
55
-
56
- useEffect(() => {
57
- const a = audioRef.current;
58
- if (!a) return;
59
- const onTime = () => setCurrentTime(a.currentTime);
60
- const onMeta = () => setDuration(a.duration || 0);
61
- const onPlay = () => setIsPlaying(true);
62
- const onPause = () => setIsPlaying(false);
63
- const onEnded = () => setIsPlaying(false);
64
- a.addEventListener("timeupdate", onTime);
65
- a.addEventListener("loadedmetadata", onMeta);
66
- a.addEventListener("durationchange", onMeta);
67
- a.addEventListener("play", onPlay);
68
- a.addEventListener("pause", onPause);
69
- a.addEventListener("ended", onEnded);
70
- return () => {
71
- a.removeEventListener("timeupdate", onTime);
72
- a.removeEventListener("loadedmetadata", onMeta);
73
- a.removeEventListener("durationchange", onMeta);
74
- a.removeEventListener("play", onPlay);
75
- a.removeEventListener("pause", onPause);
76
- a.removeEventListener("ended", onEnded);
77
- };
78
- }, []);
79
-
80
- const togglePlay = useCallback(() => {
81
- const a = audioRef.current;
82
- if (!a) return;
83
- if (a.paused) {
84
- a.play().catch(() => { /* autoplay/permission rejection — leave paused */ });
85
- } else {
86
- a.pause();
87
- }
88
- }, []);
89
-
90
- const seekFromEvent = useCallback((clientX: number) => {
91
- const el = progressRef.current;
92
- const a = audioRef.current;
93
- if (!el || !a || !duration) return;
94
- const rect = el.getBoundingClientRect();
95
- const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
96
- a.currentTime = pct * duration;
97
- setCurrentTime(a.currentTime);
98
- }, [duration]);
99
-
100
- const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
101
- draggingRef.current = true;
102
- (e.target as Element).setPointerCapture?.(e.pointerId);
103
- seekFromEvent(e.clientX);
104
- }, [seekFromEvent]);
105
-
106
- const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
107
- if (!draggingRef.current) return;
108
- seekFromEvent(e.clientX);
109
- }, [seekFromEvent]);
110
-
111
- const onPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
112
- draggingRef.current = false;
113
- (e.target as Element).releasePointerCapture?.(e.pointerId);
114
- }, []);
115
-
116
- const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
117
-
118
- const containerStyle: React.CSSProperties = {
119
- ...widthStyle,
120
- display: "flex",
121
- alignItems: "center",
122
- gap: 14,
123
- padding: "12px 16px",
124
- background: "#fafafa",
125
- border: "1px solid #ececec",
126
- borderRadius,
127
- boxShadow: block.shadow ? "0 8px 24px -12px rgba(0,0,0,0.25)" : undefined,
128
- overflow: "hidden",
129
- };
130
-
131
- const hasMetaText = !!(block.title || block.artist);
132
-
133
- return (
134
- <div style={containerStyle}>
135
- <audio
136
- ref={audioRef}
137
- src={src}
138
- autoPlay={autoplay}
139
- loop={loop}
140
- muted={muted}
141
- preload="metadata"
142
- />
143
-
144
- {coverSrc && (
145
- <div style={{ width: 52, height: 52, flexShrink: 0, borderRadius: 8, overflow: "hidden", background: "#eee" }}>
146
- {/* eslint-disable-next-line @next/next/no-img-element */}
147
- <img
148
- src={coverSrc}
149
- alt={block.alt || block.title || "Audio cover"}
150
- onError={handleImageRetry}
151
- style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
152
- />
153
- </div>
154
- )}
155
-
156
- <button
157
- type="button"
158
- onClick={togglePlay}
159
- aria-label={isPlaying ? "Pause" : "Play"}
160
- style={{
161
- width: 40,
162
- height: 40,
163
- flexShrink: 0,
164
- borderRadius: "50%",
165
- border: "none",
166
- background: accent,
167
- color: "#fff",
168
- cursor: "pointer",
169
- display: "flex",
170
- alignItems: "center",
171
- justifyContent: "center",
172
- padding: 0,
173
- }}
174
- >
175
- {isPlaying ? (
176
- <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
177
- <rect x="6" y="5" width="4" height="14" rx="1" />
178
- <rect x="14" y="5" width="4" height="14" rx="1" />
179
- </svg>
180
- ) : (
181
- <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden style={{ marginLeft: 2 }}>
182
- <path d="M8 5v14l11-7z" />
183
- </svg>
184
- )}
185
- </button>
186
-
187
- <div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 4 }}>
188
- {hasMetaText && (
189
- <div style={{ display: "flex", alignItems: "baseline", gap: 6, minWidth: 0 }}>
190
- {block.title && (
191
- <span style={{ fontSize: 13, fontWeight: 600, color: "#111", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
192
- {block.title}
193
- </span>
194
- )}
195
- {block.artist && (
196
- <span style={{ fontSize: 12, color: "#777", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
197
- {block.artist}
198
- </span>
199
- )}
200
- </div>
201
- )}
202
- <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
203
- <div
204
- ref={progressRef}
205
- onPointerDown={onPointerDown}
206
- onPointerMove={onPointerMove}
207
- onPointerUp={onPointerUp}
208
- onPointerCancel={onPointerUp}
209
- role="slider"
210
- aria-label="Playback position"
211
- aria-valuemin={0}
212
- aria-valuemax={100}
213
- aria-valuenow={Math.round(progressPct)}
214
- style={{
215
- flex: 1,
216
- height: 4,
217
- background: "#e5e5e5",
218
- borderRadius: 999,
219
- position: "relative",
220
- cursor: "pointer",
221
- touchAction: "none",
222
- }}
223
- >
224
- <div
225
- style={{
226
- position: "absolute",
227
- inset: 0,
228
- width: `${progressPct}%`,
229
- background: accent,
230
- borderRadius: 999,
231
- }}
232
- />
233
- <div
234
- style={{
235
- position: "absolute",
236
- top: "50%",
237
- left: `${progressPct}%`,
238
- width: 10,
239
- height: 10,
240
- marginTop: -5,
241
- marginLeft: -5,
242
- borderRadius: "50%",
243
- background: "#fff",
244
- boxShadow: `0 0 0 2px ${accent}`,
245
- }}
246
- />
247
- </div>
248
- <span style={{ fontSize: 11, color: "#777", fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap" }}>
249
- {formatTime(currentTime)} / {formatTime(duration)}
250
- </span>
251
- <button
252
- type="button"
253
- onClick={() => setMuted((m) => !m)}
254
- aria-label={muted ? "Unmute" : "Mute"}
255
- style={{
256
- width: 24,
257
- height: 24,
258
- flexShrink: 0,
259
- border: "none",
260
- background: "transparent",
261
- color: "#777",
262
- cursor: "pointer",
263
- padding: 0,
264
- display: "flex",
265
- alignItems: "center",
266
- justifyContent: "center",
267
- }}
268
- >
269
- {muted ? (
270
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
271
- <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
272
- <line x1="23" y1="9" x2="17" y2="15" />
273
- <line x1="17" y1="9" x2="23" y2="15" />
274
- </svg>
275
- ) : (
276
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
277
- <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
278
- <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
279
- </svg>
280
- )}
281
- </button>
282
- </div>
283
- </div>
284
- </div>
285
- );
286
- }
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import type { AudioBlock } from "../../lib/sanity/types";
5
+ import { useAssetUrl } from "../../lib/contexts/AssetContext";
6
+ import { handleImageRetry } from "../../lib/asset-retry";
7
+
8
+ /**
9
+ * AudioBlockRenderer — minimal custom audio player.
10
+ *
11
+ * Layout: optional cover art + (title / artist) + play button + progress bar + time.
12
+ * Accent color applies to the play button background and progress fill.
13
+ * Uses the HTML5 Audio element via a ref; progress + duration driven by
14
+ * timeupdate / loadedmetadata events.
15
+ */
16
+
17
+ const widthStyleMap: Record<string, { width: string; margin?: string }> = {
18
+ full: { width: "100%" },
19
+ contained: { width: "75%", margin: "0 auto" },
20
+ small: { width: "50%", margin: "0 auto" },
21
+ };
22
+
23
+ function formatTime(seconds: number): string {
24
+ if (!isFinite(seconds) || seconds < 0) return "0:00";
25
+ const m = Math.floor(seconds / 60);
26
+ const s = Math.floor(seconds % 60);
27
+ return `${m}:${s.toString().padStart(2, "0")}`;
28
+ }
29
+
30
+ export default function AudioBlockRenderer({ block }: { block: AudioBlock }) {
31
+ const resolveAsset = useAssetUrl();
32
+
33
+ const src = resolveAsset(block.asset_path);
34
+ const coverSrc = block.cover_path ? resolveAsset(block.cover_path) : null;
35
+ const accent = block.accent_color || "#3580f9";
36
+
37
+ const isFill = block.width === "fill";
38
+ const widthStyle = isFill ? {} : (widthStyleMap[block.width ?? "contained"] || widthStyleMap.contained);
39
+
40
+ const rawRadius = block.border_radius ? String(block.border_radius).replace(/[a-z%]+$/i, "") : "";
41
+ const borderRadius = rawRadius && !isNaN(Number(rawRadius)) ? `${rawRadius}px` : "12px";
42
+
43
+ const autoplay = block.autoplay === true;
44
+ const loop = block.loop === true;
45
+ const initialMuted = block.muted === true;
46
+
47
+ const audioRef = useRef<HTMLAudioElement | null>(null);
48
+ const progressRef = useRef<HTMLDivElement | null>(null);
49
+ const draggingRef = useRef(false);
50
+
51
+ const [isPlaying, setIsPlaying] = useState<boolean>(false);
52
+ const [currentTime, setCurrentTime] = useState<number>(0);
53
+ const [duration, setDuration] = useState<number>(0);
54
+ const [muted, setMuted] = useState<boolean>(initialMuted);
55
+
56
+ useEffect(() => {
57
+ const a = audioRef.current;
58
+ if (!a) return;
59
+ const onTime = () => setCurrentTime(a.currentTime);
60
+ const onMeta = () => setDuration(a.duration || 0);
61
+ const onPlay = () => setIsPlaying(true);
62
+ const onPause = () => setIsPlaying(false);
63
+ const onEnded = () => setIsPlaying(false);
64
+ a.addEventListener("timeupdate", onTime);
65
+ a.addEventListener("loadedmetadata", onMeta);
66
+ a.addEventListener("durationchange", onMeta);
67
+ a.addEventListener("play", onPlay);
68
+ a.addEventListener("pause", onPause);
69
+ a.addEventListener("ended", onEnded);
70
+ return () => {
71
+ a.removeEventListener("timeupdate", onTime);
72
+ a.removeEventListener("loadedmetadata", onMeta);
73
+ a.removeEventListener("durationchange", onMeta);
74
+ a.removeEventListener("play", onPlay);
75
+ a.removeEventListener("pause", onPause);
76
+ a.removeEventListener("ended", onEnded);
77
+ };
78
+ }, []);
79
+
80
+ const togglePlay = useCallback(() => {
81
+ const a = audioRef.current;
82
+ if (!a) return;
83
+ if (a.paused) {
84
+ a.play().catch(() => { /* autoplay/permission rejection — leave paused */ });
85
+ } else {
86
+ a.pause();
87
+ }
88
+ }, []);
89
+
90
+ const seekFromEvent = useCallback((clientX: number) => {
91
+ const el = progressRef.current;
92
+ const a = audioRef.current;
93
+ if (!el || !a || !duration) return;
94
+ const rect = el.getBoundingClientRect();
95
+ const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
96
+ a.currentTime = pct * duration;
97
+ setCurrentTime(a.currentTime);
98
+ }, [duration]);
99
+
100
+ const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
101
+ draggingRef.current = true;
102
+ (e.target as Element).setPointerCapture?.(e.pointerId);
103
+ seekFromEvent(e.clientX);
104
+ }, [seekFromEvent]);
105
+
106
+ const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
107
+ if (!draggingRef.current) return;
108
+ seekFromEvent(e.clientX);
109
+ }, [seekFromEvent]);
110
+
111
+ const onPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
112
+ draggingRef.current = false;
113
+ (e.target as Element).releasePointerCapture?.(e.pointerId);
114
+ }, []);
115
+
116
+ const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
117
+
118
+ const containerStyle: React.CSSProperties = {
119
+ ...widthStyle,
120
+ display: "flex",
121
+ alignItems: "center",
122
+ gap: 14,
123
+ padding: "12px 16px",
124
+ background: "#fafafa",
125
+ border: "1px solid #ececec",
126
+ borderRadius,
127
+ boxShadow: block.shadow ? "0 8px 24px -12px rgba(0,0,0,0.25)" : undefined,
128
+ overflow: "hidden",
129
+ };
130
+
131
+ const hasMetaText = !!(block.title || block.artist);
132
+
133
+ return (
134
+ <div style={containerStyle}>
135
+ <audio
136
+ ref={audioRef}
137
+ src={src}
138
+ autoPlay={autoplay}
139
+ loop={loop}
140
+ muted={muted}
141
+ preload="metadata"
142
+ />
143
+
144
+ {coverSrc && (
145
+ <div style={{ width: 52, height: 52, flexShrink: 0, borderRadius: 8, overflow: "hidden", background: "#eee" }}>
146
+ {/* eslint-disable-next-line @next/next/no-img-element */}
147
+ <img
148
+ src={coverSrc}
149
+ alt={block.alt || block.title || "Audio cover"}
150
+ onError={handleImageRetry}
151
+ style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
152
+ />
153
+ </div>
154
+ )}
155
+
156
+ <button
157
+ type="button"
158
+ onClick={togglePlay}
159
+ aria-label={isPlaying ? "Pause" : "Play"}
160
+ style={{
161
+ width: 40,
162
+ height: 40,
163
+ flexShrink: 0,
164
+ borderRadius: "50%",
165
+ border: "none",
166
+ background: accent,
167
+ color: "#fff",
168
+ cursor: "pointer",
169
+ display: "flex",
170
+ alignItems: "center",
171
+ justifyContent: "center",
172
+ padding: 0,
173
+ }}
174
+ >
175
+ {isPlaying ? (
176
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
177
+ <rect x="6" y="5" width="4" height="14" rx="1" />
178
+ <rect x="14" y="5" width="4" height="14" rx="1" />
179
+ </svg>
180
+ ) : (
181
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden style={{ marginLeft: 2 }}>
182
+ <path d="M8 5v14l11-7z" />
183
+ </svg>
184
+ )}
185
+ </button>
186
+
187
+ <div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 4 }}>
188
+ {hasMetaText && (
189
+ <div style={{ display: "flex", alignItems: "baseline", gap: 6, minWidth: 0 }}>
190
+ {block.title && (
191
+ <span style={{ fontSize: 13, fontWeight: 600, color: "#111", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
192
+ {block.title}
193
+ </span>
194
+ )}
195
+ {block.artist && (
196
+ <span style={{ fontSize: 12, color: "#777", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
197
+ {block.artist}
198
+ </span>
199
+ )}
200
+ </div>
201
+ )}
202
+ <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
203
+ <div
204
+ ref={progressRef}
205
+ onPointerDown={onPointerDown}
206
+ onPointerMove={onPointerMove}
207
+ onPointerUp={onPointerUp}
208
+ onPointerCancel={onPointerUp}
209
+ role="slider"
210
+ aria-label="Playback position"
211
+ aria-valuemin={0}
212
+ aria-valuemax={100}
213
+ aria-valuenow={Math.round(progressPct)}
214
+ style={{
215
+ flex: 1,
216
+ height: 4,
217
+ background: "#e5e5e5",
218
+ borderRadius: 999,
219
+ position: "relative",
220
+ cursor: "pointer",
221
+ touchAction: "none",
222
+ }}
223
+ >
224
+ <div
225
+ style={{
226
+ position: "absolute",
227
+ inset: 0,
228
+ width: `${progressPct}%`,
229
+ background: accent,
230
+ borderRadius: 999,
231
+ }}
232
+ />
233
+ <div
234
+ style={{
235
+ position: "absolute",
236
+ top: "50%",
237
+ left: `${progressPct}%`,
238
+ width: 10,
239
+ height: 10,
240
+ marginTop: -5,
241
+ marginLeft: -5,
242
+ borderRadius: "50%",
243
+ background: "#fff",
244
+ boxShadow: `0 0 0 2px ${accent}`,
245
+ }}
246
+ />
247
+ </div>
248
+ <span style={{ fontSize: 11, color: "#777", fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap" }}>
249
+ {formatTime(currentTime)} / {formatTime(duration)}
250
+ </span>
251
+ <button
252
+ type="button"
253
+ onClick={() => setMuted((m) => !m)}
254
+ aria-label={muted ? "Unmute" : "Mute"}
255
+ style={{
256
+ width: 24,
257
+ height: 24,
258
+ flexShrink: 0,
259
+ border: "none",
260
+ background: "transparent",
261
+ color: "#777",
262
+ cursor: "pointer",
263
+ padding: 0,
264
+ display: "flex",
265
+ alignItems: "center",
266
+ justifyContent: "center",
267
+ }}
268
+ >
269
+ {muted ? (
270
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
271
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
272
+ <line x1="23" y1="9" x2="17" y2="15" />
273
+ <line x1="17" y1="9" x2="23" y2="15" />
274
+ </svg>
275
+ ) : (
276
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
277
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
278
+ <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
279
+ </svg>
280
+ )}
281
+ </button>
282
+ </div>
283
+ </div>
284
+ </div>
285
+ );
286
+ }
@@ -21,7 +21,7 @@ import type { EnterAnimationConfig } from "../../lib/animation/enter-types";
21
21
  import { resolveEnterAnimation } from "../../lib/animation/enter-resolve";
22
22
  import BlockRenderer from "./BlockRenderer";
23
23
  import EnterAnimationWrapper from "./EnterAnimationWrapper";
24
- import { getBlockAlignmentStyles, hasBlockAlignment, getColumnVerticalAlign } from "../../lib/builder/layout-styles";
24
+ import { getBlockAlignmentStyles, hasBlockAlignment, getColumnVerticalAlign, getBackgroundStyles, getBorderStyles } from "../../lib/builder/layout-styles";
25
25
  import { assetUrl } from "../../lib/assets";
26
26
  import { BREAKPOINTS } from "../../lib/builder/constants";
27
27
  import { normalizeRowHeights } from "../../lib/builder/store-cover";
@@ -305,6 +305,11 @@ export default function CoverSectionRenderer({ section, pageEnterAnimation }: Co
305
305
  const alignSelf = rowAlign === "center" ? "center" : rowAlign === "end" ? "end" : "start";
306
306
  const colJustify = getColumnVerticalAlign(col.blocks || []);
307
307
 
308
+ const colLayoutStyles = {
309
+ ...getBackgroundStyles(col, process.env.NEXT_PUBLIC_ASSET_BASE_URL),
310
+ ...getBorderStyles(col),
311
+ };
312
+
308
313
  const columnContent = (
309
314
  <div
310
315
  key={col._key}
@@ -319,6 +324,7 @@ export default function CoverSectionRenderer({ section, pageEnterAnimation }: Co
319
324
  height: "100%",
320
325
  minWidth: 0,
321
326
  overflow: "hidden",
327
+ ...colLayoutStyles,
322
328
  }}
323
329
  >
324
330
  {(col.blocks || []).map((block) => {