@morphika/andami 0.1.2 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/app/(site)/[slug]/page.tsx +2 -2
  2. package/app/(site)/layout.tsx +1 -0
  3. package/app/(site)/page.tsx +2 -2
  4. package/app/(site)/preview/page.tsx +4 -4
  5. package/app/(site)/work/[slug]/page.tsx +2 -2
  6. package/app/admin/layout.tsx +2 -2
  7. package/app/admin/login/page.tsx +5 -5
  8. package/app/admin/navigation/page.tsx +255 -157
  9. package/app/api/admin/assets/relink/confirm/route.ts +1 -1
  10. package/app/api/admin/pages/[slug]/route.ts +1 -1
  11. package/app/api/admin/settings/route.ts +40 -15
  12. package/app/api/admin/setup/complete/route.ts +1 -1
  13. package/app/api/admin/setup/route.ts +6 -3
  14. package/components/admin/index.ts +7 -0
  15. package/components/admin/nav-builder/NavGeneralSettings.tsx +11 -15
  16. package/components/admin/nav-builder/NavItemSettings.tsx +29 -5
  17. package/components/admin/nav-builder/NavLivePreview.tsx +4 -1
  18. package/components/admin/nav-builder/NavMobileLivePreview.tsx +226 -0
  19. package/components/admin/nav-builder/NavMobileSettings.tsx +223 -0
  20. package/components/admin/nav-builder/index.ts +2 -0
  21. package/components/blocks/BlockRenderer.tsx +65 -13
  22. package/components/blocks/ButtonBlockRenderer.tsx +29 -6
  23. package/components/blocks/CoverBlockRenderer.tsx +36 -14
  24. package/components/blocks/ImageBlockRenderer.tsx +5 -3
  25. package/components/blocks/ImageGridBlockRenderer.tsx +13 -6
  26. package/components/blocks/PageRenderer.tsx +4 -2
  27. package/components/blocks/ProjectGridBlockRenderer.tsx +18 -3
  28. package/components/blocks/SectionRenderer.tsx +9 -8
  29. package/components/blocks/SectionV2Renderer.tsx +8 -8
  30. package/components/blocks/SpacerBlockRenderer.tsx +4 -2
  31. package/components/blocks/TextBlockRenderer.tsx +9 -4
  32. package/components/builder/BuilderCanvas.tsx +10 -4
  33. package/components/builder/ColorPicker.tsx +51 -243
  34. package/components/builder/ColorSwatchPicker.tsx +214 -274
  35. package/components/builder/DndWrapper.tsx +5 -2
  36. package/components/builder/SectionV2Canvas.tsx +15 -4
  37. package/components/builder/asset-browser/useAssetBrowser.ts +9 -1
  38. package/components/builder/color-picker/AlphaSlider.tsx +141 -0
  39. package/components/builder/color-picker/AngleControl.tsx +138 -0
  40. package/components/builder/color-picker/ColorInputs.tsx +105 -0
  41. package/components/builder/color-picker/EyedropperButton.tsx +74 -0
  42. package/components/builder/color-picker/GradientBar.tsx +222 -0
  43. package/components/builder/color-picker/GradientPreview.tsx +53 -0
  44. package/components/builder/color-picker/HueSlider.tsx +124 -0
  45. package/components/builder/color-picker/MeshCanvas.tsx +172 -0
  46. package/components/builder/color-picker/MeshPointEditor.tsx +133 -0
  47. package/components/builder/color-picker/MeshPointList.tsx +200 -0
  48. package/components/builder/color-picker/PositionControl.tsx +158 -0
  49. package/components/builder/color-picker/SaturationCanvas.tsx +142 -0
  50. package/components/builder/color-picker/StopEditor.tsx +178 -0
  51. package/components/builder/color-picker/SwatchBar.tsx +93 -0
  52. package/components/builder/color-picker/UnifiedColorPicker.tsx +713 -0
  53. package/components/builder/color-picker/index.ts +62 -0
  54. package/components/builder/color-picker/types.ts +115 -0
  55. package/components/builder/color-picker/utils.ts +138 -0
  56. package/components/builder/editors/CoverBlockEditor.tsx +86 -32
  57. package/components/builder/editors/ProjectGridEditor.tsx +51 -4
  58. package/components/builder/hooks/useColumnDrag.ts +25 -27
  59. package/components/builder/settings-panel/BlockLayoutTab.tsx +29 -7
  60. package/components/builder/settings-panel/LayoutTab.tsx +382 -310
  61. package/components/builder/settings-panel/PageSettings.tsx +6 -4
  62. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +2 -2
  63. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +392 -312
  64. package/components/builder/settings-panel/SectionV2Settings.tsx +65 -35
  65. package/components/ui/Navbar.tsx +95 -25
  66. package/components/ui/PortfolioTracker.tsx +3 -3
  67. package/lib/assets.ts +1 -1
  68. package/lib/auth.ts +1 -1
  69. package/lib/builder/gradient-presets.ts +128 -0
  70. package/lib/builder/layout-styles.ts +16 -10
  71. package/lib/builder/serializer.ts +1 -0
  72. package/lib/builder/store-blocks.ts +48 -61
  73. package/lib/builder/store-helpers.ts +31 -14
  74. package/lib/builder/store.ts +59 -41
  75. package/lib/builder/types.ts +14 -0
  76. package/lib/color-utils.ts +200 -0
  77. package/lib/config/index.ts +14 -43
  78. package/lib/revalidate.ts +2 -2
  79. package/lib/sanity/queries.ts +4 -3
  80. package/lib/sanity/types.ts +76 -1
  81. package/lib/setup/detect.ts +1 -1
  82. package/package.json +8 -12
  83. package/sanity/schemas/siteSettings.ts +34 -0
  84. package/styles/base.css +7 -51
  85. package/app/globals.css +0 -7
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ /**
4
+ * GradientPreview — Live CSS preview of a gradient value.
5
+ *
6
+ * Pure CSS rendering (no canvas). Shows a rounded preview box
7
+ * with the current gradient applied. Used in Linear, Radial,
8
+ * and Mesh gradient tabs.
9
+ */
10
+
11
+ import type { GradientValue } from "./types";
12
+ import { colorToCSS } from "../../../lib/color-utils";
13
+
14
+ export interface GradientPreviewProps {
15
+ /** Current gradient value */
16
+ value: GradientValue;
17
+ /** Height in pixels (default 160) */
18
+ height?: number;
19
+ }
20
+
21
+ export default function GradientPreview({
22
+ value,
23
+ height = 160,
24
+ }: GradientPreviewProps) {
25
+ const cssValue = colorToCSS(value);
26
+
27
+ // For mesh gradients, colorToCSS returns layered radial-gradients + background color
28
+ // separated by comma. We need backgroundImage for layers and backgroundColor for base.
29
+ const isMesh = value.type === "mesh";
30
+ const style: React.CSSProperties = isMesh
31
+ ? {
32
+ backgroundImage: value.points
33
+ .map(
34
+ (p) =>
35
+ `radial-gradient(at ${p.x}% ${p.y}%, ${p.color} 0%, transparent 50%)`
36
+ )
37
+ .join(", "),
38
+ backgroundColor: value.background,
39
+ height,
40
+ }
41
+ : {
42
+ backgroundImage: cssValue,
43
+ height,
44
+ };
45
+
46
+ return (
47
+ <div
48
+ className="w-full rounded-xl mb-4 border border-neutral-200"
49
+ style={style}
50
+ aria-label={`${value.type} gradient preview`}
51
+ />
52
+ );
53
+ }
@@ -0,0 +1,124 @@
1
+ "use client";
2
+
3
+ /**
4
+ * HueSlider — Horizontal rainbow hue selector (0–360).
5
+ *
6
+ * Mouse + touch + keyboard support.
7
+ */
8
+
9
+ import { useRef, useCallback, useEffect } from "react";
10
+ import type { HueSliderProps } from "./types";
11
+ import { clamp } from "./utils";
12
+
13
+ export default function HueSlider({ hue, onChange }: HueSliderProps) {
14
+ const trackRef = useRef<HTMLDivElement>(null);
15
+ const dragging = useRef(false);
16
+
17
+ const computeFromX = useCallback(
18
+ (clientX: number) => {
19
+ const rect = trackRef.current?.getBoundingClientRect();
20
+ if (!rect) return;
21
+ const x = clamp((clientX - rect.left) / rect.width, 0, 1);
22
+ onChange(Math.round(x * 360));
23
+ },
24
+ [onChange]
25
+ );
26
+
27
+ const handleMouseDown = useCallback(
28
+ (e: React.MouseEvent) => {
29
+ e.preventDefault();
30
+ dragging.current = true;
31
+ computeFromX(e.clientX);
32
+ },
33
+ [computeFromX]
34
+ );
35
+
36
+ const handleTouchStart = useCallback(
37
+ (e: React.TouchEvent) => {
38
+ e.preventDefault();
39
+ dragging.current = true;
40
+ computeFromX(e.touches[0].clientX);
41
+ },
42
+ [computeFromX]
43
+ );
44
+
45
+ const handleTouchMove = useCallback(
46
+ (e: React.TouchEvent) => {
47
+ if (!dragging.current) return;
48
+ e.preventDefault();
49
+ computeFromX(e.touches[0].clientX);
50
+ },
51
+ [computeFromX]
52
+ );
53
+
54
+ useEffect(() => {
55
+ const handleMove = (e: MouseEvent) => {
56
+ if (!dragging.current) return;
57
+ computeFromX(e.clientX);
58
+ };
59
+ const handleUp = () => {
60
+ dragging.current = false;
61
+ };
62
+ window.addEventListener("mousemove", handleMove);
63
+ window.addEventListener("mouseup", handleUp);
64
+ window.addEventListener("touchend", handleUp);
65
+ return () => {
66
+ window.removeEventListener("mousemove", handleMove);
67
+ window.removeEventListener("mouseup", handleUp);
68
+ window.removeEventListener("touchend", handleUp);
69
+ };
70
+ }, [computeFromX]);
71
+
72
+ const handleKeyDown = useCallback(
73
+ (e: React.KeyboardEvent) => {
74
+ const step = e.shiftKey ? 20 : 5;
75
+ let newHue = hue;
76
+ switch (e.key) {
77
+ case "ArrowRight":
78
+ newHue = clamp(hue + step, 0, 360);
79
+ break;
80
+ case "ArrowLeft":
81
+ newHue = clamp(hue - step, 0, 360);
82
+ break;
83
+ default:
84
+ return;
85
+ }
86
+ e.preventDefault();
87
+ onChange(newHue);
88
+ },
89
+ [hue, onChange]
90
+ );
91
+
92
+ return (
93
+ <div
94
+ ref={trackRef}
95
+ role="slider"
96
+ tabIndex={0}
97
+ aria-label="Color hue"
98
+ aria-valuemin={0}
99
+ aria-valuemax={360}
100
+ aria-valuenow={hue}
101
+ aria-valuetext={`Hue ${hue} degrees`}
102
+ className="w-full h-3.5 rounded-full cursor-pointer relative select-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#076bff]"
103
+ style={{
104
+ background:
105
+ "linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)",
106
+ }}
107
+ onMouseDown={handleMouseDown}
108
+ onTouchStart={handleTouchStart}
109
+ onTouchMove={handleTouchMove}
110
+ onKeyDown={handleKeyDown}
111
+ >
112
+ <div
113
+ className="absolute w-[18px] h-[18px] rounded-full border-[2.5px] border-white pointer-events-none"
114
+ style={{
115
+ left: `${(hue / 360) * 100}%`,
116
+ top: "50%",
117
+ transform: "translate(-50%, -50%)",
118
+ background: `hsl(${hue}, 100%, 50%)`,
119
+ boxShadow: "0 1px 4px rgba(0,0,0,0.4)",
120
+ }}
121
+ />
122
+ </div>
123
+ );
124
+ }
@@ -0,0 +1,172 @@
1
+ "use client";
2
+
3
+ /**
4
+ * MeshCanvas — Canvas with draggable color points for mesh gradient editing.
5
+ *
6
+ * Renders the mesh gradient as layered CSS radial-gradients with
7
+ * draggable point handles. Supports mouse drag to reposition points
8
+ * and click to select a point for color editing.
9
+ */
10
+
11
+ import { useRef, useCallback, useEffect } from "react";
12
+ import type { MeshPoint } from "./types";
13
+ import { clamp } from "./utils";
14
+
15
+ export interface MeshCanvasProps {
16
+ /** Array of mesh points */
17
+ points: MeshPoint[];
18
+ /** Background color of the mesh */
19
+ background: string;
20
+ /** Callback when points change */
21
+ onChange: (points: MeshPoint[]) => void;
22
+ /** Currently selected point index */
23
+ selectedIndex: number;
24
+ /** Callback when a point is selected */
25
+ onSelect: (index: number) => void;
26
+ }
27
+
28
+ export default function MeshCanvas({
29
+ points,
30
+ background,
31
+ onChange,
32
+ selectedIndex,
33
+ onSelect,
34
+ }: MeshCanvasProps) {
35
+ const canvasRef = useRef<HTMLDivElement>(null);
36
+ const dragging = useRef<number | null>(null);
37
+
38
+ // Build CSS background
39
+ const gradientLayers = points
40
+ .map(
41
+ (p) =>
42
+ `radial-gradient(at ${p.x}% ${p.y}%, ${p.color} 0%, transparent 50%)`
43
+ )
44
+ .join(", ");
45
+
46
+ const posFromEvent = useCallback(
47
+ (clientX: number, clientY: number): { x: number; y: number } => {
48
+ const rect = canvasRef.current?.getBoundingClientRect();
49
+ if (!rect) return { x: 50, y: 50 };
50
+ return {
51
+ x: Math.round(clamp((clientX - rect.left) / rect.width * 100, 0, 100)),
52
+ y: Math.round(clamp((clientY - rect.top) / rect.height * 100, 0, 100)),
53
+ };
54
+ },
55
+ []
56
+ );
57
+
58
+ const handlePointMouseDown = useCallback(
59
+ (index: number, e: React.MouseEvent) => {
60
+ e.stopPropagation();
61
+ e.preventDefault();
62
+ dragging.current = index;
63
+ onSelect(index);
64
+ },
65
+ [onSelect]
66
+ );
67
+
68
+ useEffect(() => {
69
+ const handleMove = (e: MouseEvent) => {
70
+ if (dragging.current === null) return;
71
+ const pos = posFromEvent(e.clientX, e.clientY);
72
+ const updated = points.map((p, i) =>
73
+ i === dragging.current ? { ...p, x: pos.x, y: pos.y } : p
74
+ );
75
+ onChange(updated);
76
+ };
77
+ const handleUp = () => {
78
+ dragging.current = null;
79
+ };
80
+ window.addEventListener("mousemove", handleMove);
81
+ window.addEventListener("mouseup", handleUp);
82
+ return () => {
83
+ window.removeEventListener("mousemove", handleMove);
84
+ window.removeEventListener("mouseup", handleUp);
85
+ };
86
+ }, [points, onChange, posFromEvent]);
87
+
88
+ // Click on empty area to add a new point
89
+ const handleCanvasClick = useCallback(
90
+ (e: React.MouseEvent) => {
91
+ if ((e.target as HTMLElement).closest("[data-mesh-point]")) return;
92
+ const pos = posFromEvent(e.clientX, e.clientY);
93
+ const newPoint: MeshPoint = { color: "#888888", x: pos.x, y: pos.y };
94
+ const newPoints = [...points, newPoint];
95
+ onChange(newPoints);
96
+ onSelect(newPoints.length - 1);
97
+ },
98
+ [points, onChange, onSelect, posFromEvent]
99
+ );
100
+
101
+ // Keyboard: arrow keys to move selected point
102
+ const handleKeyDown = useCallback(
103
+ (e: React.KeyboardEvent) => {
104
+ if (selectedIndex < 0 || selectedIndex >= points.length) return;
105
+ const step = e.shiftKey ? 5 : 1;
106
+ const p = points[selectedIndex];
107
+ let newX = p.x;
108
+ let newY = p.y;
109
+ switch (e.key) {
110
+ case "ArrowRight": newX = clamp(p.x + step, 0, 100); break;
111
+ case "ArrowLeft": newX = clamp(p.x - step, 0, 100); break;
112
+ case "ArrowUp": newY = clamp(p.y - step, 0, 100); break;
113
+ case "ArrowDown": newY = clamp(p.y + step, 0, 100); break;
114
+ case "Delete":
115
+ case "Backspace":
116
+ if (points.length > 2) {
117
+ e.preventDefault();
118
+ const newPoints = points.filter((_, i) => i !== selectedIndex);
119
+ onChange(newPoints);
120
+ onSelect(Math.min(selectedIndex, newPoints.length - 1));
121
+ }
122
+ return;
123
+ default:
124
+ return;
125
+ }
126
+ e.preventDefault();
127
+ const updated = points.map((pt, i) =>
128
+ i === selectedIndex ? { ...pt, x: newX, y: newY } : pt
129
+ );
130
+ onChange(updated);
131
+ },
132
+ [points, selectedIndex, onChange, onSelect]
133
+ );
134
+
135
+ return (
136
+ <div
137
+ ref={canvasRef}
138
+ className="aspect-square rounded-xl relative overflow-hidden cursor-crosshair border border-neutral-200"
139
+ style={{
140
+ backgroundImage: gradientLayers || undefined,
141
+ backgroundColor: background,
142
+ }}
143
+ onClick={handleCanvasClick}
144
+ onKeyDown={handleKeyDown}
145
+ tabIndex={0}
146
+ role="application"
147
+ aria-label="Mesh gradient canvas — drag points to reposition"
148
+ >
149
+ {/* Point handles */}
150
+ {points.map((point, i) => (
151
+ <div
152
+ key={i}
153
+ data-mesh-point
154
+ className={`absolute rounded-full border-[2.5px] cursor-grab transition-shadow ${
155
+ i === selectedIndex
156
+ ? "w-6 h-6 border-white shadow-[0_0_0_3px_rgba(0,0,0,0.15),0_2px_8px_rgba(0,0,0,0.3)]"
157
+ : "w-[22px] h-[22px] border-white/80 shadow-[0_2px_8px_rgba(0,0,0,0.25)]"
158
+ }`}
159
+ style={{
160
+ left: `${point.x}%`,
161
+ top: `${point.y}%`,
162
+ transform: "translate(-50%, -50%)",
163
+ backgroundColor: point.color,
164
+ }}
165
+ onMouseDown={(e) => handlePointMouseDown(i, e)}
166
+ role="button"
167
+ aria-label={`Mesh point ${i + 1}: ${point.color} at ${point.x}%, ${point.y}%`}
168
+ />
169
+ ))}
170
+ </div>
171
+ );
172
+ }
@@ -0,0 +1,133 @@
1
+ "use client";
2
+
3
+ /**
4
+ * MeshPointEditor — Mini editor for an individual mesh gradient point.
5
+ *
6
+ * Shows:
7
+ * - SaturationCanvas + HueSlider for visual color editing
8
+ * - Hex input for direct text entry
9
+ *
10
+ * Analogous to StopEditor but for mesh points (no alpha/position controls).
11
+ * Displayed below the MeshCanvas when a point is selected.
12
+ */
13
+
14
+ import { useState, useCallback, useEffect } from "react";
15
+ import SaturationCanvas from "./SaturationCanvas";
16
+ import HueSlider from "./HueSlider";
17
+ import type { MeshPoint } from "./types";
18
+ import { hexToHSV, hsvToHex, isValidHex } from "./utils";
19
+
20
+ export interface MeshPointEditorProps {
21
+ /** The mesh point being edited */
22
+ point: MeshPoint;
23
+ /** Callback when the point's color changes */
24
+ onChange: (point: MeshPoint) => void;
25
+ }
26
+
27
+ export default function MeshPointEditor({ point, onChange }: MeshPointEditorProps) {
28
+ const initHsv = hexToHSV(isValidHex(point.color) ? point.color : "#ffffff");
29
+ const [hue, setHue] = useState(initHsv.h);
30
+ const [sat, setSat] = useState(initHsv.s);
31
+ const [val, setVal] = useState(initHsv.v);
32
+ const [hexInput, setHexInput] = useState(point.color.toUpperCase());
33
+
34
+ // Sync when point changes externally (e.g. selecting a different point)
35
+ useEffect(() => {
36
+ const hsv = hexToHSV(isValidHex(point.color) ? point.color : "#ffffff");
37
+ setHue(hsv.h);
38
+ setSat(hsv.s);
39
+ setVal(hsv.v);
40
+ setHexInput(point.color.toUpperCase());
41
+ }, [point.color, point.x, point.y]);
42
+
43
+ const emitChange = useCallback(
44
+ (color: string) => {
45
+ onChange({ ...point, color });
46
+ },
47
+ [onChange, point]
48
+ );
49
+
50
+ // SaturationCanvas change
51
+ const handleSatValChange = useCallback(
52
+ (s: number, v: number) => {
53
+ setSat(s);
54
+ setVal(v);
55
+ const newHex = hsvToHex(hue, s, v);
56
+ setHexInput(newHex.toUpperCase());
57
+ emitChange(newHex);
58
+ },
59
+ [hue, emitChange]
60
+ );
61
+
62
+ // Hue change
63
+ const handleHueChange = useCallback(
64
+ (h: number) => {
65
+ setHue(h);
66
+ const newHex = hsvToHex(h, sat, val);
67
+ setHexInput(newHex.toUpperCase());
68
+ emitChange(newHex);
69
+ },
70
+ [sat, val, emitChange]
71
+ );
72
+
73
+ // Hex input
74
+ const handleHexInput = useCallback(
75
+ (e: React.ChangeEvent<HTMLInputElement>) => {
76
+ let raw = e.target.value;
77
+ setHexInput(raw);
78
+ if (!raw.startsWith("#")) raw = "#" + raw;
79
+ if (isValidHex(raw)) {
80
+ const hsv = hexToHSV(raw);
81
+ setHue(hsv.h);
82
+ setSat(hsv.s);
83
+ setVal(hsv.v);
84
+ emitChange(raw.toLowerCase());
85
+ }
86
+ },
87
+ [emitChange]
88
+ );
89
+
90
+ const currentHex = hsvToHex(hue, sat, val);
91
+
92
+ return (
93
+ <div className="border border-neutral-200 rounded-xl p-3 bg-neutral-50">
94
+ <div className="text-[10px] text-neutral-400 uppercase tracking-wider mb-2">
95
+ Point Color
96
+ </div>
97
+
98
+ {/* Compact saturation canvas */}
99
+ <div className="mb-3">
100
+ <SaturationCanvas
101
+ hue={hue}
102
+ saturation={sat}
103
+ value={val}
104
+ onChange={handleSatValChange}
105
+ />
106
+ </div>
107
+
108
+ {/* Hue slider */}
109
+ <div className="mb-3">
110
+ <HueSlider hue={hue} onChange={handleHueChange} />
111
+ </div>
112
+
113
+ {/* Hex input row */}
114
+ <div className="flex gap-2 items-center">
115
+ {/* Color preview */}
116
+ <div
117
+ className="w-7 h-7 rounded-md border border-neutral-200 shrink-0"
118
+ style={{ backgroundColor: currentHex }}
119
+ />
120
+
121
+ {/* Hex input */}
122
+ <input
123
+ type="text"
124
+ value={hexInput}
125
+ onChange={handleHexInput}
126
+ onBlur={() => setHexInput(point.color.toUpperCase())}
127
+ className="flex-1 bg-white border border-neutral-200 rounded-lg px-2 py-1.5 text-neutral-900 text-[12px] font-mono outline-none focus:border-neutral-400"
128
+ aria-label="Point color hex"
129
+ />
130
+ </div>
131
+ </div>
132
+ );
133
+ }
@@ -0,0 +1,200 @@
1
+ "use client";
2
+
3
+ /**
4
+ * MeshPointList — Sidebar list of mesh gradient points with editable hex + add/remove.
5
+ *
6
+ * Each point shows a color dot, hex input, and coordinates.
7
+ * Add button at the bottom. Remove on right-click or delete button.
8
+ * Min 2 points enforced.
9
+ */
10
+
11
+ import { useCallback, useState, useEffect } from "react";
12
+ import type { MeshPoint } from "./types";
13
+ import { isValidHex } from "./utils";
14
+
15
+ export interface MeshPointListProps {
16
+ /** Array of mesh points */
17
+ points: MeshPoint[];
18
+ /** Callback when points change */
19
+ onChange: (points: MeshPoint[]) => void;
20
+ /** Currently selected point index */
21
+ selectedIndex: number;
22
+ /** Callback when a point is selected */
23
+ onSelect: (index: number) => void;
24
+ /** Background color of the mesh */
25
+ background: string;
26
+ /** Callback when background color changes */
27
+ onBackgroundChange: (hex: string) => void;
28
+ }
29
+
30
+ export default function MeshPointList({
31
+ points,
32
+ onChange,
33
+ selectedIndex,
34
+ onSelect,
35
+ background,
36
+ onBackgroundChange,
37
+ }: MeshPointListProps) {
38
+ const [bgInput, setBgInput] = useState(background.toUpperCase());
39
+
40
+ useEffect(() => {
41
+ setBgInput(background.toUpperCase());
42
+ }, [background]);
43
+
44
+ const handleHexChange = useCallback(
45
+ (index: number, raw: string) => {
46
+ let hex = raw;
47
+ if (!hex.startsWith("#")) hex = "#" + hex;
48
+ if (isValidHex(hex)) {
49
+ const updated = points.map((p, i) =>
50
+ i === index ? { ...p, color: hex.toLowerCase() } : p
51
+ );
52
+ onChange(updated);
53
+ }
54
+ },
55
+ [points, onChange]
56
+ );
57
+
58
+ const handleAdd = useCallback(() => {
59
+ const newPoint: MeshPoint = {
60
+ color: "#888888",
61
+ x: Math.round(Math.random() * 60 + 20),
62
+ y: Math.round(Math.random() * 60 + 20),
63
+ };
64
+ const newPoints = [...points, newPoint];
65
+ onChange(newPoints);
66
+ onSelect(newPoints.length - 1);
67
+ }, [points, onChange, onSelect]);
68
+
69
+ const handleRemove = useCallback(
70
+ (index: number) => {
71
+ if (points.length <= 2) return;
72
+ const newPoints = points.filter((_, i) => i !== index);
73
+ onChange(newPoints);
74
+ onSelect(Math.min(index, newPoints.length - 1));
75
+ },
76
+ [points, onChange, onSelect]
77
+ );
78
+
79
+ const handleBgInput = useCallback(
80
+ (e: React.ChangeEvent<HTMLInputElement>) => {
81
+ const raw = e.target.value;
82
+ setBgInput(raw);
83
+ let hex = raw;
84
+ if (!hex.startsWith("#")) hex = "#" + hex;
85
+ if (isValidHex(hex)) {
86
+ onBackgroundChange(hex.toLowerCase());
87
+ }
88
+ },
89
+ [onBackgroundChange]
90
+ );
91
+
92
+ return (
93
+ <div className="flex flex-col gap-2">
94
+ <div className="text-[10px] text-neutral-400 uppercase tracking-wider mb-0.5">
95
+ Points
96
+ </div>
97
+
98
+ {points.map((point, i) => (
99
+ <div
100
+ key={i}
101
+ className={`flex items-center gap-2 px-2 py-1.5 rounded-lg border cursor-pointer transition-colors ${
102
+ i === selectedIndex
103
+ ? "border-neutral-400 bg-white"
104
+ : "border-neutral-200 bg-neutral-50 hover:border-neutral-300"
105
+ }`}
106
+ onClick={() => onSelect(i)}
107
+ >
108
+ {/* Color dot */}
109
+ <div
110
+ className="w-5 h-5 rounded-md shrink-0 border border-neutral-200"
111
+ style={{ backgroundColor: point.color }}
112
+ />
113
+ {/* Hex input */}
114
+ <HexInput
115
+ value={point.color}
116
+ onChange={(hex) => handleHexChange(i, hex)}
117
+ />
118
+ {/* Remove button */}
119
+ {points.length > 2 && (
120
+ <button
121
+ type="button"
122
+ onClick={(e) => {
123
+ e.stopPropagation();
124
+ handleRemove(i);
125
+ }}
126
+ className="text-neutral-300 hover:text-red-500 transition-colors shrink-0 text-xs"
127
+ aria-label={`Remove point ${i + 1}`}
128
+ >
129
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
130
+ <path d="M3 3L9 9M9 3L3 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
131
+ </svg>
132
+ </button>
133
+ )}
134
+ </div>
135
+ ))}
136
+
137
+ {/* Add point button */}
138
+ <button
139
+ type="button"
140
+ onClick={handleAdd}
141
+ className="flex items-center justify-center w-7 h-7 rounded-full border-[1.5px] border-dashed border-neutral-300 bg-transparent cursor-pointer text-neutral-400 text-sm hover:border-neutral-400 hover:text-neutral-500 transition-colors self-center mt-1"
142
+ aria-label="Add mesh point"
143
+ >
144
+ +
145
+ </button>
146
+
147
+ {/* Background color */}
148
+ <div className="mt-2 pt-2 border-t border-neutral-200">
149
+ <div className="text-[10px] text-neutral-400 uppercase tracking-wider mb-1.5">
150
+ Background
151
+ </div>
152
+ <div className="flex items-center gap-2">
153
+ <div
154
+ className="w-5 h-5 rounded-md shrink-0 border border-neutral-200"
155
+ style={{ backgroundColor: background }}
156
+ />
157
+ <input
158
+ type="text"
159
+ value={bgInput}
160
+ onChange={handleBgInput}
161
+ onBlur={() => setBgInput(background.toUpperCase())}
162
+ className="flex-1 bg-white border border-neutral-200 rounded-md px-2 py-1 text-neutral-900 text-[11px] font-mono outline-none focus:border-neutral-400"
163
+ aria-label="Mesh background color"
164
+ />
165
+ </div>
166
+ </div>
167
+ </div>
168
+ );
169
+ }
170
+
171
+ // ─── Internal hex input component ───
172
+
173
+ function HexInput({
174
+ value,
175
+ onChange,
176
+ }: {
177
+ value: string;
178
+ onChange: (hex: string) => void;
179
+ }) {
180
+ const [input, setInput] = useState(value.toUpperCase());
181
+
182
+ useEffect(() => {
183
+ setInput(value.toUpperCase());
184
+ }, [value]);
185
+
186
+ return (
187
+ <input
188
+ type="text"
189
+ value={input}
190
+ onChange={(e) => {
191
+ setInput(e.target.value);
192
+ onChange(e.target.value);
193
+ }}
194
+ onBlur={() => setInput(value.toUpperCase())}
195
+ onClick={(e) => e.stopPropagation()}
196
+ className="flex-1 min-w-0 bg-transparent text-neutral-700 text-[11px] font-mono outline-none"
197
+ aria-label="Point color hex"
198
+ />
199
+ );
200
+ }