@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,158 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PositionControl — Center X/Y control for radial gradients.
|
|
5
|
+
*
|
|
6
|
+
* Provides a mini clickable/draggable canvas showing the gradient center,
|
|
7
|
+
* plus numeric inputs for X and Y percentages (0-100).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useRef, useCallback, useEffect, useState } from "react";
|
|
11
|
+
import { clamp } from "./utils";
|
|
12
|
+
|
|
13
|
+
export interface PositionControlProps {
|
|
14
|
+
/** Center X position 0-100 */
|
|
15
|
+
x: number;
|
|
16
|
+
/** Center Y position 0-100 */
|
|
17
|
+
y: number;
|
|
18
|
+
/** Callback when position changes */
|
|
19
|
+
onChange: (x: number, y: number) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default function PositionControl({
|
|
23
|
+
x,
|
|
24
|
+
y,
|
|
25
|
+
onChange,
|
|
26
|
+
}: PositionControlProps) {
|
|
27
|
+
const canvasRef = useRef<HTMLDivElement>(null);
|
|
28
|
+
const dragging = useRef(false);
|
|
29
|
+
const [xInput, setXInput] = useState(String(x));
|
|
30
|
+
const [yInput, setYInput] = useState(String(y));
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!dragging.current) {
|
|
34
|
+
setXInput(String(x));
|
|
35
|
+
setYInput(String(y));
|
|
36
|
+
}
|
|
37
|
+
}, [x, y]);
|
|
38
|
+
|
|
39
|
+
const computeFromEvent = useCallback(
|
|
40
|
+
(clientX: number, clientY: number) => {
|
|
41
|
+
const rect = canvasRef.current?.getBoundingClientRect();
|
|
42
|
+
if (!rect) return;
|
|
43
|
+
const newX = Math.round(clamp((clientX - rect.left) / rect.width * 100, 0, 100));
|
|
44
|
+
const newY = Math.round(clamp((clientY - rect.top) / rect.height * 100, 0, 100));
|
|
45
|
+
onChange(newX, newY);
|
|
46
|
+
setXInput(String(newX));
|
|
47
|
+
setYInput(String(newY));
|
|
48
|
+
},
|
|
49
|
+
[onChange]
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const handleMouseDown = useCallback(
|
|
53
|
+
(e: React.MouseEvent) => {
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
dragging.current = true;
|
|
56
|
+
computeFromEvent(e.clientX, e.clientY);
|
|
57
|
+
},
|
|
58
|
+
[computeFromEvent]
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const handleMove = (e: MouseEvent) => {
|
|
63
|
+
if (!dragging.current) return;
|
|
64
|
+
computeFromEvent(e.clientX, e.clientY);
|
|
65
|
+
};
|
|
66
|
+
const handleUp = () => {
|
|
67
|
+
dragging.current = false;
|
|
68
|
+
};
|
|
69
|
+
window.addEventListener("mousemove", handleMove);
|
|
70
|
+
window.addEventListener("mouseup", handleUp);
|
|
71
|
+
return () => {
|
|
72
|
+
window.removeEventListener("mousemove", handleMove);
|
|
73
|
+
window.removeEventListener("mouseup", handleUp);
|
|
74
|
+
};
|
|
75
|
+
}, [computeFromEvent]);
|
|
76
|
+
|
|
77
|
+
const handleXInput = useCallback(
|
|
78
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
79
|
+
setXInput(e.target.value);
|
|
80
|
+
const num = parseInt(e.target.value, 10);
|
|
81
|
+
if (!isNaN(num)) onChange(clamp(num, 0, 100), y);
|
|
82
|
+
},
|
|
83
|
+
[onChange, y]
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const handleYInput = useCallback(
|
|
87
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
88
|
+
setYInput(e.target.value);
|
|
89
|
+
const num = parseInt(e.target.value, 10);
|
|
90
|
+
if (!isNaN(num)) onChange(x, clamp(num, 0, 100));
|
|
91
|
+
},
|
|
92
|
+
[onChange, x]
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className="flex items-start gap-3 mb-4">
|
|
97
|
+
{/* Mini canvas */}
|
|
98
|
+
<div
|
|
99
|
+
ref={canvasRef}
|
|
100
|
+
className="w-[72px] h-[72px] rounded-lg border border-neutral-200 bg-neutral-50 relative cursor-crosshair shrink-0"
|
|
101
|
+
onMouseDown={handleMouseDown}
|
|
102
|
+
role="slider"
|
|
103
|
+
aria-label="Radial gradient center position"
|
|
104
|
+
aria-valuetext={`X ${x}%, Y ${y}%`}
|
|
105
|
+
tabIndex={0}
|
|
106
|
+
onKeyDown={(e) => {
|
|
107
|
+
const step = e.shiftKey ? 10 : 2;
|
|
108
|
+
if (e.key === "ArrowRight") { e.preventDefault(); onChange(clamp(x + step, 0, 100), y); }
|
|
109
|
+
else if (e.key === "ArrowLeft") { e.preventDefault(); onChange(clamp(x - step, 0, 100), y); }
|
|
110
|
+
else if (e.key === "ArrowUp") { e.preventDefault(); onChange(x, clamp(y - step, 0, 100)); }
|
|
111
|
+
else if (e.key === "ArrowDown") { e.preventDefault(); onChange(x, clamp(y + step, 0, 100)); }
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
{/* Crosshair */}
|
|
115
|
+
<div
|
|
116
|
+
className="absolute w-3 h-3 rounded-full border-2 border-neutral-900 bg-white pointer-events-none"
|
|
117
|
+
style={{
|
|
118
|
+
left: `${x}%`,
|
|
119
|
+
top: `${y}%`,
|
|
120
|
+
transform: "translate(-50%, -50%)",
|
|
121
|
+
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
|
|
122
|
+
}}
|
|
123
|
+
/>
|
|
124
|
+
{/* Grid lines for reference */}
|
|
125
|
+
<div className="absolute inset-0 pointer-events-none opacity-20">
|
|
126
|
+
<div className="absolute top-1/2 left-0 right-0 h-px bg-neutral-400" />
|
|
127
|
+
<div className="absolute left-1/2 top-0 bottom-0 w-px bg-neutral-400" />
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{/* Numeric inputs */}
|
|
132
|
+
<div className="flex flex-col gap-1.5">
|
|
133
|
+
<label className="flex items-center gap-1.5">
|
|
134
|
+
<span className="text-[10px] text-neutral-400 uppercase w-3">X</span>
|
|
135
|
+
<input
|
|
136
|
+
type="text"
|
|
137
|
+
value={xInput}
|
|
138
|
+
onChange={handleXInput}
|
|
139
|
+
onBlur={() => setXInput(String(x))}
|
|
140
|
+
className="w-12 bg-neutral-50 border border-neutral-200 rounded-md px-1.5 py-1 text-neutral-900 text-[11px] font-mono text-center outline-none focus:border-neutral-400"
|
|
141
|
+
/>
|
|
142
|
+
<span className="text-[10px] text-neutral-400">%</span>
|
|
143
|
+
</label>
|
|
144
|
+
<label className="flex items-center gap-1.5">
|
|
145
|
+
<span className="text-[10px] text-neutral-400 uppercase w-3">Y</span>
|
|
146
|
+
<input
|
|
147
|
+
type="text"
|
|
148
|
+
value={yInput}
|
|
149
|
+
onChange={handleYInput}
|
|
150
|
+
onBlur={() => setYInput(String(y))}
|
|
151
|
+
className="w-12 bg-neutral-50 border border-neutral-200 rounded-md px-1.5 py-1 text-neutral-900 text-[11px] font-mono text-center outline-none focus:border-neutral-400"
|
|
152
|
+
/>
|
|
153
|
+
<span className="text-[10px] text-neutral-400">%</span>
|
|
154
|
+
</label>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SaturationCanvas — HSV saturation/brightness 2D canvas.
|
|
5
|
+
*
|
|
6
|
+
* Mouse + touch + keyboard support.
|
|
7
|
+
* Renders a hue-tinted gradient with white→color horizontally
|
|
8
|
+
* and transparent→black vertically.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useRef, useCallback, useEffect } from "react";
|
|
12
|
+
import type { SaturationCanvasProps } from "./types";
|
|
13
|
+
import { clamp } from "./utils";
|
|
14
|
+
|
|
15
|
+
export default function SaturationCanvas({
|
|
16
|
+
hue,
|
|
17
|
+
saturation,
|
|
18
|
+
value,
|
|
19
|
+
onChange,
|
|
20
|
+
}: SaturationCanvasProps) {
|
|
21
|
+
const canvasRef = useRef<HTMLDivElement>(null);
|
|
22
|
+
const dragging = useRef(false);
|
|
23
|
+
|
|
24
|
+
const computeFromEvent = useCallback(
|
|
25
|
+
(clientX: number, clientY: number) => {
|
|
26
|
+
const rect = canvasRef.current?.getBoundingClientRect();
|
|
27
|
+
if (!rect) return;
|
|
28
|
+
const x = clamp((clientX - rect.left) / rect.width, 0, 1);
|
|
29
|
+
const y = clamp((clientY - rect.top) / rect.height, 0, 1);
|
|
30
|
+
onChange(Math.round(x * 100), Math.round((1 - y) * 100));
|
|
31
|
+
},
|
|
32
|
+
[onChange]
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// Mouse events
|
|
36
|
+
const handleMouseDown = useCallback(
|
|
37
|
+
(e: React.MouseEvent) => {
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
dragging.current = true;
|
|
40
|
+
computeFromEvent(e.clientX, e.clientY);
|
|
41
|
+
},
|
|
42
|
+
[computeFromEvent]
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// Touch events
|
|
46
|
+
const handleTouchStart = useCallback(
|
|
47
|
+
(e: React.TouchEvent) => {
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
dragging.current = true;
|
|
50
|
+
const t = e.touches[0];
|
|
51
|
+
computeFromEvent(t.clientX, t.clientY);
|
|
52
|
+
},
|
|
53
|
+
[computeFromEvent]
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const handleTouchMove = useCallback(
|
|
57
|
+
(e: React.TouchEvent) => {
|
|
58
|
+
if (!dragging.current) return;
|
|
59
|
+
e.preventDefault();
|
|
60
|
+
const t = e.touches[0];
|
|
61
|
+
computeFromEvent(t.clientX, t.clientY);
|
|
62
|
+
},
|
|
63
|
+
[computeFromEvent]
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// Global mouse move/up
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const handleMove = (e: MouseEvent) => {
|
|
69
|
+
if (!dragging.current) return;
|
|
70
|
+
computeFromEvent(e.clientX, e.clientY);
|
|
71
|
+
};
|
|
72
|
+
const handleUp = () => {
|
|
73
|
+
dragging.current = false;
|
|
74
|
+
};
|
|
75
|
+
window.addEventListener("mousemove", handleMove);
|
|
76
|
+
window.addEventListener("mouseup", handleUp);
|
|
77
|
+
window.addEventListener("touchend", handleUp);
|
|
78
|
+
return () => {
|
|
79
|
+
window.removeEventListener("mousemove", handleMove);
|
|
80
|
+
window.removeEventListener("mouseup", handleUp);
|
|
81
|
+
window.removeEventListener("touchend", handleUp);
|
|
82
|
+
};
|
|
83
|
+
}, [computeFromEvent]);
|
|
84
|
+
|
|
85
|
+
// Keyboard
|
|
86
|
+
const handleKeyDown = useCallback(
|
|
87
|
+
(e: React.KeyboardEvent) => {
|
|
88
|
+
const step = e.shiftKey ? 10 : 2;
|
|
89
|
+
let newSat = saturation;
|
|
90
|
+
let newVal = value;
|
|
91
|
+
switch (e.key) {
|
|
92
|
+
case "ArrowRight":
|
|
93
|
+
newSat = clamp(saturation + step, 0, 100);
|
|
94
|
+
break;
|
|
95
|
+
case "ArrowLeft":
|
|
96
|
+
newSat = clamp(saturation - step, 0, 100);
|
|
97
|
+
break;
|
|
98
|
+
case "ArrowUp":
|
|
99
|
+
newVal = clamp(value + step, 0, 100);
|
|
100
|
+
break;
|
|
101
|
+
case "ArrowDown":
|
|
102
|
+
newVal = clamp(value - step, 0, 100);
|
|
103
|
+
break;
|
|
104
|
+
default:
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
onChange(newSat, newVal);
|
|
109
|
+
},
|
|
110
|
+
[saturation, value, onChange]
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div
|
|
115
|
+
ref={canvasRef}
|
|
116
|
+
role="slider"
|
|
117
|
+
tabIndex={0}
|
|
118
|
+
aria-label="Color saturation and brightness"
|
|
119
|
+
aria-valuetext={`Saturation ${saturation}%, Brightness ${value}%`}
|
|
120
|
+
className="w-full h-[220px] rounded-xl cursor-crosshair relative select-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#076bff]"
|
|
121
|
+
style={{
|
|
122
|
+
background: `linear-gradient(to bottom, transparent, #000), linear-gradient(to right, #fff, hsl(${hue}, 100%, 50%))`,
|
|
123
|
+
}}
|
|
124
|
+
onMouseDown={handleMouseDown}
|
|
125
|
+
onTouchStart={handleTouchStart}
|
|
126
|
+
onTouchMove={handleTouchMove}
|
|
127
|
+
onKeyDown={handleKeyDown}
|
|
128
|
+
>
|
|
129
|
+
{/* Cursor indicator */}
|
|
130
|
+
<div
|
|
131
|
+
className="absolute w-5 h-5 rounded-full border-[2.5px] border-white pointer-events-none"
|
|
132
|
+
style={{
|
|
133
|
+
left: `${saturation}%`,
|
|
134
|
+
top: `${100 - value}%`,
|
|
135
|
+
transform: "translate(-50%, -50%)",
|
|
136
|
+
boxShadow:
|
|
137
|
+
"0 0 0 1px rgba(0,0,0,0.3), 0 2px 8px rgba(0,0,0,0.4)",
|
|
138
|
+
}}
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* StopEditor — Mini editor for an individual gradient stop.
|
|
5
|
+
*
|
|
6
|
+
* Shows:
|
|
7
|
+
* - Nested SaturationCanvas + HueSlider for color editing
|
|
8
|
+
* - Alpha slider for stop transparency
|
|
9
|
+
* - Position input (0-100%)
|
|
10
|
+
*
|
|
11
|
+
* Used inside Linear and Radial gradient tabs to edit the selected stop.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useState, useCallback, useEffect } from "react";
|
|
15
|
+
import SaturationCanvas from "./SaturationCanvas";
|
|
16
|
+
import HueSlider from "./HueSlider";
|
|
17
|
+
import AlphaSlider from "./AlphaSlider";
|
|
18
|
+
import type { GradientStop } from "./types";
|
|
19
|
+
import { hexToHSV, hsvToHex, isValidHex, clamp } from "./utils";
|
|
20
|
+
|
|
21
|
+
export interface StopEditorProps {
|
|
22
|
+
/** The stop being edited */
|
|
23
|
+
stop: GradientStop;
|
|
24
|
+
/** Callback when the stop changes */
|
|
25
|
+
onChange: (stop: GradientStop) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default function StopEditor({ stop, onChange }: StopEditorProps) {
|
|
29
|
+
const initHsv = hexToHSV(isValidHex(stop.color) ? stop.color : "#ffffff");
|
|
30
|
+
const [hue, setHue] = useState(initHsv.h);
|
|
31
|
+
const [sat, setSat] = useState(initHsv.s);
|
|
32
|
+
const [val, setVal] = useState(initHsv.v);
|
|
33
|
+
const [posInput, setPosInput] = useState(String(stop.position));
|
|
34
|
+
const [hexInput, setHexInput] = useState(stop.color.toUpperCase());
|
|
35
|
+
|
|
36
|
+
// Sync when stop changes externally (e.g. selecting a different stop)
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const hsv = hexToHSV(isValidHex(stop.color) ? stop.color : "#ffffff");
|
|
39
|
+
setHue(hsv.h);
|
|
40
|
+
setSat(hsv.s);
|
|
41
|
+
setVal(hsv.v);
|
|
42
|
+
setPosInput(String(stop.position));
|
|
43
|
+
setHexInput(stop.color.toUpperCase());
|
|
44
|
+
}, [stop.color, stop.position]);
|
|
45
|
+
|
|
46
|
+
const emitChange = useCallback(
|
|
47
|
+
(color: string, alpha: number, position: number) => {
|
|
48
|
+
onChange({ color, alpha, position });
|
|
49
|
+
},
|
|
50
|
+
[onChange]
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// SaturationCanvas change
|
|
54
|
+
const handleSatValChange = useCallback(
|
|
55
|
+
(s: number, v: number) => {
|
|
56
|
+
setSat(s);
|
|
57
|
+
setVal(v);
|
|
58
|
+
const newHex = hsvToHex(hue, s, v);
|
|
59
|
+
setHexInput(newHex.toUpperCase());
|
|
60
|
+
emitChange(newHex, stop.alpha, stop.position);
|
|
61
|
+
},
|
|
62
|
+
[hue, stop.alpha, stop.position, emitChange]
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// Hue change
|
|
66
|
+
const handleHueChange = useCallback(
|
|
67
|
+
(h: number) => {
|
|
68
|
+
setHue(h);
|
|
69
|
+
const newHex = hsvToHex(h, sat, val);
|
|
70
|
+
setHexInput(newHex.toUpperCase());
|
|
71
|
+
emitChange(newHex, stop.alpha, stop.position);
|
|
72
|
+
},
|
|
73
|
+
[sat, val, stop.alpha, stop.position, emitChange]
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// Alpha change
|
|
77
|
+
const handleAlphaChange = useCallback(
|
|
78
|
+
(a: number) => {
|
|
79
|
+
emitChange(stop.color, a, stop.position);
|
|
80
|
+
},
|
|
81
|
+
[stop.color, stop.position, emitChange]
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Hex input
|
|
85
|
+
const handleHexInput = useCallback(
|
|
86
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
87
|
+
let raw = e.target.value;
|
|
88
|
+
setHexInput(raw);
|
|
89
|
+
if (!raw.startsWith("#")) raw = "#" + raw;
|
|
90
|
+
if (isValidHex(raw)) {
|
|
91
|
+
const hsv = hexToHSV(raw);
|
|
92
|
+
setHue(hsv.h);
|
|
93
|
+
setSat(hsv.s);
|
|
94
|
+
setVal(hsv.v);
|
|
95
|
+
emitChange(raw.toLowerCase(), stop.alpha, stop.position);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
[stop.alpha, stop.position, emitChange]
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Position input
|
|
102
|
+
const handlePosInput = useCallback(
|
|
103
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
104
|
+
setPosInput(e.target.value);
|
|
105
|
+
const num = parseInt(e.target.value, 10);
|
|
106
|
+
if (!isNaN(num)) {
|
|
107
|
+
emitChange(stop.color, stop.alpha, clamp(num, 0, 100));
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
[stop.color, stop.alpha, emitChange]
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const currentHex = hsvToHex(hue, sat, val);
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className="border border-neutral-200 rounded-xl p-3 mb-4 bg-neutral-50">
|
|
117
|
+
<div className="text-[10px] text-neutral-400 uppercase tracking-wider mb-2">
|
|
118
|
+
Stop Color
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{/* Compact saturation canvas */}
|
|
122
|
+
<div className="mb-3">
|
|
123
|
+
<SaturationCanvas
|
|
124
|
+
hue={hue}
|
|
125
|
+
saturation={sat}
|
|
126
|
+
value={val}
|
|
127
|
+
onChange={handleSatValChange}
|
|
128
|
+
/>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{/* Hue slider */}
|
|
132
|
+
<div className="mb-2">
|
|
133
|
+
<HueSlider hue={hue} onChange={handleHueChange} />
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
{/* Alpha slider */}
|
|
137
|
+
<div className="mb-3">
|
|
138
|
+
<AlphaSlider
|
|
139
|
+
color={currentHex}
|
|
140
|
+
alpha={stop.alpha}
|
|
141
|
+
onChange={handleAlphaChange}
|
|
142
|
+
/>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
{/* Hex + Position row */}
|
|
146
|
+
<div className="flex gap-2 items-center">
|
|
147
|
+
{/* Color preview */}
|
|
148
|
+
<div
|
|
149
|
+
className="w-7 h-7 rounded-md border border-neutral-200 shrink-0"
|
|
150
|
+
style={{ backgroundColor: currentHex, opacity: stop.alpha }}
|
|
151
|
+
/>
|
|
152
|
+
|
|
153
|
+
{/* Hex input */}
|
|
154
|
+
<input
|
|
155
|
+
type="text"
|
|
156
|
+
value={hexInput}
|
|
157
|
+
onChange={handleHexInput}
|
|
158
|
+
onBlur={() => setHexInput(stop.color.toUpperCase())}
|
|
159
|
+
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"
|
|
160
|
+
aria-label="Stop color hex"
|
|
161
|
+
/>
|
|
162
|
+
|
|
163
|
+
{/* Position input */}
|
|
164
|
+
<div className="flex items-center gap-1">
|
|
165
|
+
<input
|
|
166
|
+
type="text"
|
|
167
|
+
value={posInput}
|
|
168
|
+
onChange={handlePosInput}
|
|
169
|
+
onBlur={() => setPosInput(String(stop.position))}
|
|
170
|
+
className="w-10 bg-white border border-neutral-200 rounded-lg px-1.5 py-1.5 text-neutral-900 text-[12px] font-mono text-center outline-none focus:border-neutral-400"
|
|
171
|
+
aria-label="Stop position"
|
|
172
|
+
/>
|
|
173
|
+
<span className="text-[10px] text-neutral-400">%</span>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SwatchBar — User palette swatches + common neutral colors.
|
|
5
|
+
*
|
|
6
|
+
* Integrated inside the color picker modal. Shows:
|
|
7
|
+
* - "Your Palette" row with user swatches + an "add to palette" button
|
|
8
|
+
* - "Common" row with standard neutrals (white → black)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useCallback } from "react";
|
|
12
|
+
import type { SwatchBarProps } from "./types";
|
|
13
|
+
|
|
14
|
+
// Common neutral colors always available
|
|
15
|
+
const COMMON_COLORS = [
|
|
16
|
+
"#ffffff",
|
|
17
|
+
"#f5f5f5",
|
|
18
|
+
"#e5e5e5",
|
|
19
|
+
"#a3a3a3",
|
|
20
|
+
"#525252",
|
|
21
|
+
"#262626",
|
|
22
|
+
"#171717",
|
|
23
|
+
"#000000",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export default function SwatchBar({
|
|
27
|
+
value,
|
|
28
|
+
onSelect,
|
|
29
|
+
swatches = [],
|
|
30
|
+
}: SwatchBarProps) {
|
|
31
|
+
const handleSwatchClick = useCallback(
|
|
32
|
+
(hex: string) => {
|
|
33
|
+
onSelect(hex);
|
|
34
|
+
},
|
|
35
|
+
[onSelect]
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="border-t border-neutral-200 pt-4">
|
|
40
|
+
{/* User palette */}
|
|
41
|
+
{swatches.length > 0 && (
|
|
42
|
+
<div className="mb-3">
|
|
43
|
+
<div className="flex items-center justify-between mb-2">
|
|
44
|
+
<span className="text-[10px] text-neutral-400 uppercase tracking-widest">
|
|
45
|
+
Your Palette
|
|
46
|
+
</span>
|
|
47
|
+
</div>
|
|
48
|
+
<div className="flex flex-wrap gap-1.5">
|
|
49
|
+
{swatches.map((s, i) => (
|
|
50
|
+
<button
|
|
51
|
+
key={s._key || `swatch-${i}`}
|
|
52
|
+
type="button"
|
|
53
|
+
onClick={() => handleSwatchClick(s.hex)}
|
|
54
|
+
title={`${s.name}: ${s.hex}`}
|
|
55
|
+
className={`w-8 h-8 rounded-lg cursor-pointer transition-all ${
|
|
56
|
+
value.toLowerCase() === s.hex.toLowerCase()
|
|
57
|
+
? "ring-2 ring-[#076bff] ring-offset-1 ring-offset-white"
|
|
58
|
+
: "border border-neutral-200 hover:border-neutral-400 hover:scale-110"
|
|
59
|
+
}`}
|
|
60
|
+
style={{ background: s.hex }}
|
|
61
|
+
/>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
|
|
67
|
+
{/* Common colors */}
|
|
68
|
+
<div>
|
|
69
|
+
{swatches.length > 0 && (
|
|
70
|
+
<span className="text-[10px] text-neutral-400 uppercase tracking-widest block mb-2">
|
|
71
|
+
Common
|
|
72
|
+
</span>
|
|
73
|
+
)}
|
|
74
|
+
<div className="flex gap-1">
|
|
75
|
+
{COMMON_COLORS.map((c) => (
|
|
76
|
+
<button
|
|
77
|
+
key={c}
|
|
78
|
+
type="button"
|
|
79
|
+
onClick={() => handleSwatchClick(c)}
|
|
80
|
+
title={c.toUpperCase()}
|
|
81
|
+
className={`w-6 h-6 rounded-md cursor-pointer transition-all ${
|
|
82
|
+
value.toLowerCase() === c
|
|
83
|
+
? "ring-2 ring-[#076bff] ring-offset-1 ring-offset-white"
|
|
84
|
+
: "border border-neutral-200 hover:border-neutral-400 hover:scale-110"
|
|
85
|
+
}`}
|
|
86
|
+
style={{ background: c }}
|
|
87
|
+
/>
|
|
88
|
+
))}
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|