@morphika/andami 0.1.3 → 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 (84) 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/revalidate.ts +2 -2
  78. package/lib/sanity/queries.ts +4 -3
  79. package/lib/sanity/types.ts +76 -1
  80. package/lib/setup/detect.ts +1 -1
  81. package/package.json +8 -2
  82. package/sanity/schemas/siteSettings.ts +34 -0
  83. package/styles/base.css +3 -3
  84. 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
+ }