@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.
- package/app/(site)/[slug]/page.tsx +2 -2
- package/app/(site)/layout.tsx +1 -0
- package/app/(site)/page.tsx +2 -2
- package/app/(site)/preview/page.tsx +4 -4
- package/app/(site)/work/[slug]/page.tsx +2 -2
- package/app/admin/layout.tsx +2 -2
- package/app/admin/login/page.tsx +5 -5
- package/app/admin/navigation/page.tsx +255 -157
- package/app/api/admin/assets/relink/confirm/route.ts +1 -1
- package/app/api/admin/pages/[slug]/route.ts +1 -1
- package/app/api/admin/settings/route.ts +40 -15
- package/app/api/admin/setup/complete/route.ts +1 -1
- package/app/api/admin/setup/route.ts +6 -3
- package/components/admin/index.ts +7 -0
- package/components/admin/nav-builder/NavGeneralSettings.tsx +11 -15
- package/components/admin/nav-builder/NavItemSettings.tsx +29 -5
- package/components/admin/nav-builder/NavLivePreview.tsx +4 -1
- package/components/admin/nav-builder/NavMobileLivePreview.tsx +226 -0
- package/components/admin/nav-builder/NavMobileSettings.tsx +223 -0
- package/components/admin/nav-builder/index.ts +2 -0
- package/components/blocks/BlockRenderer.tsx +65 -13
- package/components/blocks/ButtonBlockRenderer.tsx +29 -6
- package/components/blocks/CoverBlockRenderer.tsx +36 -14
- package/components/blocks/ImageBlockRenderer.tsx +5 -3
- package/components/blocks/ImageGridBlockRenderer.tsx +13 -6
- package/components/blocks/PageRenderer.tsx +4 -2
- package/components/blocks/ProjectGridBlockRenderer.tsx +18 -3
- package/components/blocks/SectionRenderer.tsx +9 -8
- package/components/blocks/SectionV2Renderer.tsx +8 -8
- package/components/blocks/SpacerBlockRenderer.tsx +4 -2
- package/components/blocks/TextBlockRenderer.tsx +9 -4
- package/components/builder/BuilderCanvas.tsx +10 -4
- package/components/builder/ColorPicker.tsx +51 -243
- package/components/builder/ColorSwatchPicker.tsx +214 -274
- package/components/builder/DndWrapper.tsx +5 -2
- package/components/builder/SectionV2Canvas.tsx +15 -4
- package/components/builder/asset-browser/useAssetBrowser.ts +9 -1
- package/components/builder/color-picker/AlphaSlider.tsx +141 -0
- package/components/builder/color-picker/AngleControl.tsx +138 -0
- package/components/builder/color-picker/ColorInputs.tsx +105 -0
- package/components/builder/color-picker/EyedropperButton.tsx +74 -0
- package/components/builder/color-picker/GradientBar.tsx +222 -0
- package/components/builder/color-picker/GradientPreview.tsx +53 -0
- package/components/builder/color-picker/HueSlider.tsx +124 -0
- package/components/builder/color-picker/MeshCanvas.tsx +172 -0
- package/components/builder/color-picker/MeshPointEditor.tsx +133 -0
- package/components/builder/color-picker/MeshPointList.tsx +200 -0
- package/components/builder/color-picker/PositionControl.tsx +158 -0
- package/components/builder/color-picker/SaturationCanvas.tsx +142 -0
- package/components/builder/color-picker/StopEditor.tsx +178 -0
- package/components/builder/color-picker/SwatchBar.tsx +93 -0
- package/components/builder/color-picker/UnifiedColorPicker.tsx +713 -0
- package/components/builder/color-picker/index.ts +62 -0
- package/components/builder/color-picker/types.ts +115 -0
- package/components/builder/color-picker/utils.ts +138 -0
- package/components/builder/editors/CoverBlockEditor.tsx +86 -32
- package/components/builder/editors/ProjectGridEditor.tsx +51 -4
- package/components/builder/hooks/useColumnDrag.ts +25 -27
- package/components/builder/settings-panel/BlockLayoutTab.tsx +29 -7
- package/components/builder/settings-panel/LayoutTab.tsx +382 -310
- package/components/builder/settings-panel/PageSettings.tsx +6 -4
- package/components/builder/settings-panel/ParallaxSlideSettings.tsx +2 -2
- package/components/builder/settings-panel/SectionV2LayoutTab.tsx +392 -312
- package/components/builder/settings-panel/SectionV2Settings.tsx +65 -35
- package/components/ui/Navbar.tsx +95 -25
- package/components/ui/PortfolioTracker.tsx +3 -3
- package/lib/assets.ts +1 -1
- package/lib/auth.ts +1 -1
- package/lib/builder/gradient-presets.ts +128 -0
- package/lib/builder/layout-styles.ts +16 -10
- package/lib/builder/serializer.ts +1 -0
- package/lib/builder/store-blocks.ts +48 -61
- package/lib/builder/store-helpers.ts +31 -14
- package/lib/builder/store.ts +59 -41
- package/lib/builder/types.ts +14 -0
- package/lib/color-utils.ts +200 -0
- package/lib/config/index.ts +14 -43
- package/lib/revalidate.ts +2 -2
- package/lib/sanity/queries.ts +4 -3
- package/lib/sanity/types.ts +76 -1
- package/lib/setup/detect.ts +1 -1
- package/package.json +8 -12
- package/sanity/schemas/siteSettings.ts +34 -0
- package/styles/base.css +7 -51
- 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
|
+
}
|