@morphika/andami 0.1.3 → 0.1.6

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 (105) hide show
  1. package/app/(site)/[slug]/page.tsx +7 -4
  2. package/app/(site)/layout.tsx +5 -2
  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 +7 -4
  6. package/app/admin/layout.tsx +3 -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/health/route.ts +1 -1
  10. package/app/api/admin/assets/register/route.ts +1 -1
  11. package/app/api/admin/assets/registry/route.ts +1 -1
  12. package/app/api/admin/assets/relink/confirm/route.ts +2 -2
  13. package/app/api/admin/assets/relink/route.ts +1 -1
  14. package/app/api/admin/assets/scan/route.ts +1 -1
  15. package/app/api/admin/custom-sections/[slug]/route.ts +1 -1
  16. package/app/api/admin/custom-sections/route.ts +1 -1
  17. package/app/api/admin/database/route.ts +1 -1
  18. package/app/api/admin/pages/[slug]/duplicate/route.ts +1 -1
  19. package/app/api/admin/pages/[slug]/route.ts +2 -2
  20. package/app/api/admin/pages/[slug]/set-home/route.ts +1 -1
  21. package/app/api/admin/pages/route.ts +1 -1
  22. package/app/api/admin/preview/route.ts +1 -1
  23. package/app/api/admin/r2/delete/route.ts +1 -1
  24. package/app/api/admin/r2/rename/route.ts +1 -1
  25. package/app/api/admin/r2/status/route.ts +1 -1
  26. package/app/api/admin/r2/upload-url/route.ts +1 -1
  27. package/app/api/admin/settings/route.ts +41 -16
  28. package/app/api/admin/setup/complete/route.ts +2 -2
  29. package/app/api/admin/setup/route.ts +7 -4
  30. package/app/api/admin/storage/switch/route.ts +1 -1
  31. package/app/api/admin/styles/route.ts +1 -1
  32. package/components/admin/index.ts +7 -0
  33. package/components/admin/nav-builder/NavGeneralSettings.tsx +11 -15
  34. package/components/admin/nav-builder/NavItemSettings.tsx +29 -5
  35. package/components/admin/nav-builder/NavLivePreview.tsx +4 -1
  36. package/components/admin/nav-builder/NavMobileLivePreview.tsx +226 -0
  37. package/components/admin/nav-builder/NavMobileSettings.tsx +223 -0
  38. package/components/admin/nav-builder/index.ts +2 -0
  39. package/components/blocks/BlockRenderer.tsx +65 -13
  40. package/components/blocks/ButtonBlockRenderer.tsx +29 -6
  41. package/components/blocks/CoverBlockRenderer.tsx +36 -14
  42. package/components/blocks/ImageBlockRenderer.tsx +5 -3
  43. package/components/blocks/ImageGridBlockRenderer.tsx +13 -6
  44. package/components/blocks/PageRenderer.tsx +4 -2
  45. package/components/blocks/ProjectGridBlockRenderer.tsx +18 -3
  46. package/components/blocks/SectionRenderer.tsx +9 -8
  47. package/components/blocks/SectionV2Renderer.tsx +8 -8
  48. package/components/blocks/SpacerBlockRenderer.tsx +4 -2
  49. package/components/blocks/TextBlockRenderer.tsx +9 -4
  50. package/components/builder/BuilderCanvas.tsx +10 -4
  51. package/components/builder/ColorPicker.tsx +51 -243
  52. package/components/builder/ColorSwatchPicker.tsx +214 -274
  53. package/components/builder/DndWrapper.tsx +5 -2
  54. package/components/builder/SectionV2Canvas.tsx +15 -4
  55. package/components/builder/asset-browser/useAssetBrowser.ts +9 -1
  56. package/components/builder/color-picker/AlphaSlider.tsx +141 -0
  57. package/components/builder/color-picker/AngleControl.tsx +138 -0
  58. package/components/builder/color-picker/ColorInputs.tsx +105 -0
  59. package/components/builder/color-picker/EyedropperButton.tsx +74 -0
  60. package/components/builder/color-picker/GradientBar.tsx +222 -0
  61. package/components/builder/color-picker/GradientPreview.tsx +53 -0
  62. package/components/builder/color-picker/HueSlider.tsx +124 -0
  63. package/components/builder/color-picker/MeshCanvas.tsx +172 -0
  64. package/components/builder/color-picker/MeshPointEditor.tsx +133 -0
  65. package/components/builder/color-picker/MeshPointList.tsx +200 -0
  66. package/components/builder/color-picker/PositionControl.tsx +158 -0
  67. package/components/builder/color-picker/SaturationCanvas.tsx +142 -0
  68. package/components/builder/color-picker/StopEditor.tsx +178 -0
  69. package/components/builder/color-picker/SwatchBar.tsx +93 -0
  70. package/components/builder/color-picker/UnifiedColorPicker.tsx +713 -0
  71. package/components/builder/color-picker/index.ts +62 -0
  72. package/components/builder/color-picker/types.ts +115 -0
  73. package/components/builder/color-picker/utils.ts +138 -0
  74. package/components/builder/editors/CoverBlockEditor.tsx +86 -32
  75. package/components/builder/editors/ProjectGridEditor.tsx +51 -4
  76. package/components/builder/hooks/useColumnDrag.ts +25 -27
  77. package/components/builder/settings-panel/BlockLayoutTab.tsx +29 -7
  78. package/components/builder/settings-panel/LayoutTab.tsx +382 -310
  79. package/components/builder/settings-panel/PageSettings.tsx +6 -4
  80. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +2 -2
  81. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +392 -312
  82. package/components/builder/settings-panel/SectionV2Settings.tsx +65 -35
  83. package/components/ui/Navbar.tsx +97 -25
  84. package/components/ui/PortfolioTracker.tsx +3 -3
  85. package/lib/assets.ts +1 -1
  86. package/lib/auth.ts +1 -1
  87. package/lib/builder/gradient-presets.ts +128 -0
  88. package/lib/builder/layout-styles.ts +16 -10
  89. package/lib/builder/serializer.ts +1 -0
  90. package/lib/builder/store-blocks.ts +48 -61
  91. package/lib/builder/store-helpers.ts +31 -14
  92. package/lib/builder/store.ts +59 -41
  93. package/lib/builder/types.ts +14 -0
  94. package/lib/color-utils.ts +200 -0
  95. package/lib/revalidate.ts +2 -2
  96. package/lib/sanity/client.ts +16 -0
  97. package/lib/sanity/queries.ts +4 -3
  98. package/lib/sanity/types.ts +76 -1
  99. package/lib/setup/detect.ts +1 -1
  100. package/lib/storage/index.ts +22 -4
  101. package/lib/version.ts +6 -0
  102. package/package.json +8 -2
  103. package/sanity/schemas/siteSettings.ts +34 -0
  104. package/styles/base.css +3 -3
  105. package/app/globals.css +0 -7
@@ -0,0 +1,141 @@
1
+ "use client";
2
+
3
+ /**
4
+ * AlphaSlider — Transparency slider with checkerboard pattern.
5
+ *
6
+ * Shows a gradient from transparent to the current color over a
7
+ * checker background. Mouse + touch + keyboard support.
8
+ */
9
+
10
+ import { useRef, useCallback, useEffect } from "react";
11
+ import type { AlphaSliderProps } from "./types";
12
+ import { clamp } from "./utils";
13
+
14
+ export default function AlphaSlider({
15
+ color,
16
+ alpha,
17
+ onChange,
18
+ }: AlphaSliderProps) {
19
+ const trackRef = useRef<HTMLDivElement>(null);
20
+ const dragging = useRef(false);
21
+
22
+ const computeFromX = useCallback(
23
+ (clientX: number) => {
24
+ const rect = trackRef.current?.getBoundingClientRect();
25
+ if (!rect) return;
26
+ const x = clamp((clientX - rect.left) / rect.width, 0, 1);
27
+ onChange(Math.round(x * 100) / 100);
28
+ },
29
+ [onChange]
30
+ );
31
+
32
+ const handleMouseDown = useCallback(
33
+ (e: React.MouseEvent) => {
34
+ e.preventDefault();
35
+ dragging.current = true;
36
+ computeFromX(e.clientX);
37
+ },
38
+ [computeFromX]
39
+ );
40
+
41
+ const handleTouchStart = useCallback(
42
+ (e: React.TouchEvent) => {
43
+ e.preventDefault();
44
+ dragging.current = true;
45
+ computeFromX(e.touches[0].clientX);
46
+ },
47
+ [computeFromX]
48
+ );
49
+
50
+ const handleTouchMove = useCallback(
51
+ (e: React.TouchEvent) => {
52
+ if (!dragging.current) return;
53
+ e.preventDefault();
54
+ computeFromX(e.touches[0].clientX);
55
+ },
56
+ [computeFromX]
57
+ );
58
+
59
+ useEffect(() => {
60
+ const handleMove = (e: MouseEvent) => {
61
+ if (!dragging.current) return;
62
+ computeFromX(e.clientX);
63
+ };
64
+ const handleUp = () => {
65
+ dragging.current = false;
66
+ };
67
+ window.addEventListener("mousemove", handleMove);
68
+ window.addEventListener("mouseup", handleUp);
69
+ window.addEventListener("touchend", handleUp);
70
+ return () => {
71
+ window.removeEventListener("mousemove", handleMove);
72
+ window.removeEventListener("mouseup", handleUp);
73
+ window.removeEventListener("touchend", handleUp);
74
+ };
75
+ }, [computeFromX]);
76
+
77
+ const handleKeyDown = useCallback(
78
+ (e: React.KeyboardEvent) => {
79
+ const step = e.shiftKey ? 0.1 : 0.01;
80
+ let newAlpha = alpha;
81
+ switch (e.key) {
82
+ case "ArrowRight":
83
+ newAlpha = clamp(alpha + step, 0, 1);
84
+ break;
85
+ case "ArrowLeft":
86
+ newAlpha = clamp(alpha - step, 0, 1);
87
+ break;
88
+ default:
89
+ return;
90
+ }
91
+ e.preventDefault();
92
+ onChange(Math.round(newAlpha * 100) / 100);
93
+ },
94
+ [alpha, onChange]
95
+ );
96
+
97
+ return (
98
+ <div
99
+ ref={trackRef}
100
+ role="slider"
101
+ tabIndex={0}
102
+ aria-label="Color opacity"
103
+ aria-valuemin={0}
104
+ aria-valuemax={100}
105
+ aria-valuenow={Math.round(alpha * 100)}
106
+ aria-valuetext={`Opacity ${Math.round(alpha * 100)}%`}
107
+ className="w-full h-3.5 rounded-full cursor-pointer relative select-none overflow-hidden focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#076bff]"
108
+ style={{
109
+ backgroundImage:
110
+ "linear-gradient(45deg, #d4d4d4 25%, transparent 25%, transparent 75%, #d4d4d4 75%), linear-gradient(45deg, #d4d4d4 25%, transparent 25%, transparent 75%, #d4d4d4 75%)",
111
+ backgroundSize: "8px 8px",
112
+ backgroundPosition: "0 0, 4px 4px",
113
+ backgroundColor: "#f5f5f5",
114
+ }}
115
+ onMouseDown={handleMouseDown}
116
+ onTouchStart={handleTouchStart}
117
+ onTouchMove={handleTouchMove}
118
+ onKeyDown={handleKeyDown}
119
+ >
120
+ {/* Color gradient overlay */}
121
+ <div
122
+ className="absolute inset-0 rounded-full"
123
+ style={{
124
+ background: `linear-gradient(to right, transparent, ${color})`,
125
+ }}
126
+ />
127
+ {/* Thumb */}
128
+ <div
129
+ className="absolute w-[18px] h-[18px] rounded-full border-[2.5px] border-white pointer-events-none"
130
+ style={{
131
+ left: `${alpha * 100}%`,
132
+ top: "50%",
133
+ transform: "translate(-50%, -50%)",
134
+ background: color,
135
+ opacity: alpha,
136
+ boxShadow: "0 1px 4px rgba(0,0,0,0.4)",
137
+ }}
138
+ />
139
+ </div>
140
+ );
141
+ }
@@ -0,0 +1,138 @@
1
+ "use client";
2
+
3
+ /**
4
+ * AngleControl — Visual rotary dial + numeric input for gradient angle (0-360°).
5
+ *
6
+ * Used in the Linear Gradient tab. The dial can be dragged to set angle visually.
7
+ * The input accepts direct numeric entry.
8
+ */
9
+
10
+ import { useRef, useState, useCallback, useEffect } from "react";
11
+ import { clamp } from "./utils";
12
+
13
+ export interface AngleControlProps {
14
+ /** Current angle 0-360 */
15
+ angle: number;
16
+ /** Callback when angle changes */
17
+ onChange: (angle: number) => void;
18
+ }
19
+
20
+ export default function AngleControl({ angle, onChange }: AngleControlProps) {
21
+ const dialRef = useRef<HTMLDivElement>(null);
22
+ const dragging = useRef(false);
23
+ const [inputValue, setInputValue] = useState(String(angle));
24
+
25
+ // Sync input when angle changes externally
26
+ useEffect(() => {
27
+ if (!dragging.current) {
28
+ setInputValue(String(angle));
29
+ }
30
+ }, [angle]);
31
+
32
+ const computeAngle = useCallback(
33
+ (clientX: number, clientY: number) => {
34
+ const rect = dialRef.current?.getBoundingClientRect();
35
+ if (!rect) return;
36
+ const cx = rect.left + rect.width / 2;
37
+ const cy = rect.top + rect.height / 2;
38
+ const dx = clientX - cx;
39
+ const dy = clientY - cy;
40
+ // atan2 gives angle from positive X axis; we want 0° = up (north)
41
+ let deg = Math.atan2(dx, -dy) * (180 / Math.PI);
42
+ if (deg < 0) deg += 360;
43
+ const rounded = Math.round(deg);
44
+ onChange(rounded);
45
+ setInputValue(String(rounded));
46
+ },
47
+ [onChange]
48
+ );
49
+
50
+ const handleMouseDown = useCallback(
51
+ (e: React.MouseEvent) => {
52
+ e.preventDefault();
53
+ dragging.current = true;
54
+ computeAngle(e.clientX, e.clientY);
55
+ },
56
+ [computeAngle]
57
+ );
58
+
59
+ useEffect(() => {
60
+ const handleMove = (e: MouseEvent) => {
61
+ if (!dragging.current) return;
62
+ computeAngle(e.clientX, e.clientY);
63
+ };
64
+ const handleUp = () => {
65
+ dragging.current = false;
66
+ };
67
+ window.addEventListener("mousemove", handleMove);
68
+ window.addEventListener("mouseup", handleUp);
69
+ return () => {
70
+ window.removeEventListener("mousemove", handleMove);
71
+ window.removeEventListener("mouseup", handleUp);
72
+ };
73
+ }, [computeAngle]);
74
+
75
+ const handleInputChange = useCallback(
76
+ (e: React.ChangeEvent<HTMLInputElement>) => {
77
+ const raw = e.target.value;
78
+ setInputValue(raw);
79
+ const num = parseInt(raw, 10);
80
+ if (!isNaN(num)) {
81
+ onChange(clamp(((num % 360) + 360) % 360, 0, 360));
82
+ }
83
+ },
84
+ [onChange]
85
+ );
86
+
87
+ const handleInputBlur = useCallback(() => {
88
+ setInputValue(String(angle));
89
+ }, [angle]);
90
+
91
+ return (
92
+ <div className="flex items-center gap-3 mb-4">
93
+ {/* Rotary dial */}
94
+ <div
95
+ ref={dialRef}
96
+ className="w-11 h-11 rounded-full border-2 border-neutral-300 bg-neutral-50 relative cursor-grab shrink-0 hover:border-neutral-400 transition-colors"
97
+ onMouseDown={handleMouseDown}
98
+ role="slider"
99
+ aria-label="Gradient angle"
100
+ aria-valuemin={0}
101
+ aria-valuemax={360}
102
+ aria-valuenow={angle}
103
+ aria-valuetext={`${angle} degrees`}
104
+ tabIndex={0}
105
+ onKeyDown={(e) => {
106
+ const step = e.shiftKey ? 15 : 1;
107
+ if (e.key === "ArrowRight" || e.key === "ArrowUp") {
108
+ e.preventDefault();
109
+ onChange((angle + step) % 360);
110
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
111
+ e.preventDefault();
112
+ onChange(((angle - step) % 360 + 360) % 360);
113
+ }
114
+ }}
115
+ >
116
+ {/* Indicator line */}
117
+ <div
118
+ className="absolute w-0.5 h-4 bg-neutral-900 left-1/2 top-1 rounded-full"
119
+ style={{
120
+ transformOrigin: "bottom center",
121
+ transform: `translateX(-50%) rotate(${angle}deg)`,
122
+ }}
123
+ />
124
+ </div>
125
+
126
+ {/* Numeric input */}
127
+ <input
128
+ type="text"
129
+ value={inputValue}
130
+ onChange={handleInputChange}
131
+ onBlur={handleInputBlur}
132
+ className="w-[60px] bg-neutral-50 border border-neutral-200 rounded-lg px-2 py-2 text-neutral-900 text-[13px] font-mono text-center outline-none focus:border-neutral-400 transition-colors"
133
+ aria-label="Angle in degrees"
134
+ />
135
+ <span className="text-xs text-neutral-400">deg</span>
136
+ </div>
137
+ );
138
+ }
@@ -0,0 +1,105 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ColorInputs — Color preview + editable input + format toggle (HEX/RGB/HSL).
5
+ *
6
+ * Shows:
7
+ * - A square color preview with checker background (for alpha)
8
+ * - An editable text input whose format changes with the toggle
9
+ * - A format toggle button cycling HEX → RGB → HSL
10
+ */
11
+
12
+ import { useState, useCallback, useEffect } from "react";
13
+ import type { ColorFormat, ColorInputsProps } from "./types";
14
+ import { formatColorValue, parseColorInput, isValidHex } from "./utils";
15
+
16
+ export default function ColorInputs({
17
+ hex,
18
+ onHexChange,
19
+ alpha = 1,
20
+ }: ColorInputsProps) {
21
+ const [format, setFormat] = useState<ColorFormat>("hex");
22
+ const [inputValue, setInputValue] = useState(
23
+ formatColorValue(hex, "hex")
24
+ );
25
+
26
+ // Sync input display when hex changes externally (e.g. from canvas drag)
27
+ useEffect(() => {
28
+ if (isValidHex(hex)) {
29
+ setInputValue(formatColorValue(hex, format));
30
+ }
31
+ }, [hex, format]);
32
+
33
+ const handleFormatToggle = useCallback(() => {
34
+ const formats: ColorFormat[] = ["hex", "rgb", "hsl"];
35
+ const nextIndex = (formats.indexOf(format) + 1) % formats.length;
36
+ const nextFormat = formats[nextIndex];
37
+ setFormat(nextFormat);
38
+ if (isValidHex(hex)) {
39
+ setInputValue(formatColorValue(hex, nextFormat));
40
+ }
41
+ }, [format, hex]);
42
+
43
+ const handleInputChange = useCallback(
44
+ (e: React.ChangeEvent<HTMLInputElement>) => {
45
+ const raw = e.target.value;
46
+ setInputValue(raw);
47
+ const parsed = parseColorInput(raw, format);
48
+ if (parsed) {
49
+ onHexChange(parsed);
50
+ }
51
+ },
52
+ [format, onHexChange]
53
+ );
54
+
55
+ const handleInputBlur = useCallback(() => {
56
+ // Reset to current valid value on blur
57
+ if (isValidHex(hex)) {
58
+ setInputValue(formatColorValue(hex, format));
59
+ }
60
+ }, [hex, format]);
61
+
62
+ return (
63
+ <div className="flex items-center gap-2">
64
+ {/* Color preview square */}
65
+ <div
66
+ className="w-10 h-10 rounded-[10px] border border-neutral-200 shrink-0 relative overflow-hidden"
67
+ >
68
+ {/* Checker background for alpha visibility */}
69
+ <div
70
+ className="absolute inset-0"
71
+ style={{
72
+ backgroundImage:
73
+ "linear-gradient(45deg, #d4d4d4 25%, transparent 25%, transparent 75%, #d4d4d4 75%), linear-gradient(45deg, #d4d4d4 25%, transparent 25%, transparent 75%, #d4d4d4 75%)",
74
+ backgroundSize: "8px 8px",
75
+ backgroundPosition: "0 0, 4px 4px",
76
+ backgroundColor: "#f5f5f5",
77
+ }}
78
+ />
79
+ {/* Color fill */}
80
+ <div
81
+ className="absolute inset-0"
82
+ style={{ background: hex, opacity: alpha }}
83
+ />
84
+ </div>
85
+
86
+ {/* Editable input */}
87
+ <input
88
+ value={inputValue}
89
+ onChange={handleInputChange}
90
+ onBlur={handleInputBlur}
91
+ spellCheck={false}
92
+ className="flex-1 bg-neutral-50 border border-neutral-200 rounded-[10px] px-3 py-2.5 text-neutral-900 text-sm font-mono outline-none focus:border-[#076bff] focus:ring-2 focus:ring-[#076bff]/10 transition-colors"
93
+ />
94
+
95
+ {/* Format toggle */}
96
+ <button
97
+ type="button"
98
+ onClick={handleFormatToggle}
99
+ className="bg-neutral-50 border border-neutral-200 rounded-[10px] px-3 py-2.5 text-neutral-400 text-[11px] uppercase tracking-wide cursor-pointer hover:text-neutral-600 hover:border-neutral-300 transition-colors font-sans"
100
+ >
101
+ {format.toUpperCase()}
102
+ </button>
103
+ </div>
104
+ );
105
+ }
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ /**
4
+ * EyedropperButton — Uses the browser's EyeDropper API to pick a color
5
+ * from anywhere on screen. Gracefully disabled in unsupported browsers.
6
+ *
7
+ * Supported: Chrome, Edge, Arc, Brave (Chromium-based).
8
+ * Not supported: Firefox, Safari.
9
+ */
10
+
11
+ import { useState, useCallback } from "react";
12
+ import type { EyedropperButtonProps } from "./types";
13
+
14
+ /** Check if the EyeDropper API is available */
15
+ function isEyeDropperSupported(): boolean {
16
+ return typeof window !== "undefined" && "EyeDropper" in window;
17
+ }
18
+
19
+ export default function EyedropperButton({
20
+ onColorPicked,
21
+ }: EyedropperButtonProps) {
22
+ const supported = isEyeDropperSupported();
23
+ const [picking, setPicking] = useState(false);
24
+
25
+ const handleClick = useCallback(async () => {
26
+ if (!supported || picking) return;
27
+ setPicking(true);
28
+ try {
29
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
+ const dropper = new (window as any).EyeDropper();
31
+ const result = await dropper.open();
32
+ if (result?.sRGBHex) {
33
+ onColorPicked(result.sRGBHex.toLowerCase());
34
+ }
35
+ } catch {
36
+ // User cancelled or API error — silently ignore
37
+ } finally {
38
+ setPicking(false);
39
+ }
40
+ }, [supported, picking, onColorPicked]);
41
+
42
+ return (
43
+ <button
44
+ type="button"
45
+ onClick={handleClick}
46
+ disabled={!supported}
47
+ title={
48
+ supported
49
+ ? "Pick a color from screen"
50
+ : "Eyedropper not supported in this browser"
51
+ }
52
+ className={`w-10 h-10 rounded-[10px] border shrink-0 flex items-center justify-center transition-colors ${
53
+ supported
54
+ ? "border-neutral-200 bg-neutral-50 text-neutral-500 cursor-pointer hover:border-neutral-300 hover:text-neutral-700"
55
+ : "border-neutral-100 bg-neutral-50 text-neutral-300 cursor-not-allowed"
56
+ } ${picking ? "ring-2 ring-[#076bff]/30" : ""}`}
57
+ >
58
+ <svg
59
+ width="18"
60
+ height="18"
61
+ viewBox="0 0 24 24"
62
+ fill="none"
63
+ stroke="currentColor"
64
+ strokeWidth="2"
65
+ strokeLinecap="round"
66
+ strokeLinejoin="round"
67
+ >
68
+ <path d="m2 22 1-1h3l9-9" />
69
+ <path d="M3 21v-3l9-9" />
70
+ <path d="m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3L18 9l.4.4a2.1 2.1 0 1 1-3 3l-3.8-3.8a2.1 2.1 0 1 1 3-3L15 6" />
71
+ </svg>
72
+ </button>
73
+ );
74
+ }
@@ -0,0 +1,222 @@
1
+ "use client";
2
+
3
+ /**
4
+ * GradientBar — Horizontal bar with draggable color stops.
5
+ *
6
+ * Features:
7
+ * - Drag stops to reposition (0-100%)
8
+ * - Click on empty area to add a new stop (lerps color from neighbors)
9
+ * - Click a stop to select it for editing
10
+ * - Right-click or delete key to remove (min 2 stops enforced)
11
+ *
12
+ * Used in Linear and Radial gradient tabs.
13
+ */
14
+
15
+ import { useRef, useState, useCallback, useEffect } from "react";
16
+ import type { GradientStop } from "./types";
17
+ import { clamp, hexToRgba } from "./utils";
18
+ import { lerpHex } from "../../../lib/color-utils";
19
+
20
+ export interface GradientBarProps {
21
+ /** Array of gradient stops */
22
+ stops: GradientStop[];
23
+ /** Callback when stops change */
24
+ onChange: (stops: GradientStop[]) => void;
25
+ /** Currently selected stop index */
26
+ selectedIndex: number;
27
+ /** Callback when a stop is selected */
28
+ onSelect: (index: number) => void;
29
+ /** CSS gradient string for the bar background (caller generates from the full gradient config) */
30
+ gradientCSS: string;
31
+ }
32
+
33
+ export default function GradientBar({
34
+ stops,
35
+ onChange,
36
+ selectedIndex,
37
+ onSelect,
38
+ gradientCSS,
39
+ }: GradientBarProps) {
40
+ const barRef = useRef<HTMLDivElement>(null);
41
+ const dragging = useRef<number | null>(null);
42
+
43
+ // ─── Drag stop ───
44
+
45
+ const positionFromEvent = useCallback(
46
+ (clientX: number): number => {
47
+ const rect = barRef.current?.getBoundingClientRect();
48
+ if (!rect) return 0;
49
+ return Math.round(clamp((clientX - rect.left) / rect.width * 100, 0, 100));
50
+ },
51
+ []
52
+ );
53
+
54
+ const handleStopMouseDown = useCallback(
55
+ (index: number, e: React.MouseEvent) => {
56
+ e.stopPropagation();
57
+ e.preventDefault();
58
+ dragging.current = index;
59
+ onSelect(index);
60
+ },
61
+ [onSelect]
62
+ );
63
+
64
+ useEffect(() => {
65
+ const handleMove = (e: MouseEvent) => {
66
+ if (dragging.current === null) return;
67
+ const pos = positionFromEvent(e.clientX);
68
+ const updated = stops.map((s, i) =>
69
+ i === dragging.current ? { ...s, position: pos } : s
70
+ );
71
+ onChange(updated);
72
+ };
73
+ const handleUp = () => {
74
+ if (dragging.current !== null) {
75
+ // Sort stops by position after drag
76
+ const sorted = [...stops].sort((a, b) => a.position - b.position);
77
+ // Find new index of the previously dragged stop
78
+ const draggedStop = stops[dragging.current!];
79
+ const newIndex = sorted.findIndex((s) => s === draggedStop);
80
+ onChange(sorted);
81
+ if (newIndex !== -1) onSelect(newIndex);
82
+ dragging.current = null;
83
+ }
84
+ };
85
+ window.addEventListener("mousemove", handleMove);
86
+ window.addEventListener("mouseup", handleUp);
87
+ return () => {
88
+ window.removeEventListener("mousemove", handleMove);
89
+ window.removeEventListener("mouseup", handleUp);
90
+ };
91
+ }, [stops, onChange, onSelect, positionFromEvent]);
92
+
93
+ // ─── Add stop on bar click ───
94
+
95
+ const handleBarClick = useCallback(
96
+ (e: React.MouseEvent) => {
97
+ // Don't add if clicked on a stop handle
98
+ if ((e.target as HTMLElement).closest("[data-stop]")) return;
99
+
100
+ const pos = positionFromEvent(e.clientX);
101
+ // Lerp color from neighboring stops
102
+ const sorted = [...stops].sort((a, b) => a.position - b.position);
103
+ let color = "#888888";
104
+ let alpha = 1;
105
+
106
+ // Find surrounding stops
107
+ let leftStop = sorted[0];
108
+ let rightStop = sorted[sorted.length - 1];
109
+ for (let i = 0; i < sorted.length - 1; i++) {
110
+ if (sorted[i].position <= pos && sorted[i + 1].position >= pos) {
111
+ leftStop = sorted[i];
112
+ rightStop = sorted[i + 1];
113
+ break;
114
+ }
115
+ }
116
+ if (leftStop && rightStop && leftStop !== rightStop) {
117
+ const range = rightStop.position - leftStop.position;
118
+ const t = range > 0 ? (pos - leftStop.position) / range : 0.5;
119
+ color = lerpHex(leftStop.color, rightStop.color, t);
120
+ alpha = leftStop.alpha + (rightStop.alpha - leftStop.alpha) * t;
121
+ alpha = Math.round(alpha * 100) / 100;
122
+ }
123
+
124
+ const newStop: GradientStop = { color, alpha, position: pos };
125
+ const newStops = [...stops, newStop].sort((a, b) => a.position - b.position);
126
+ const newIndex = newStops.findIndex((s) => s === newStop);
127
+ onChange(newStops);
128
+ onSelect(newIndex);
129
+ },
130
+ [stops, onChange, onSelect, positionFromEvent]
131
+ );
132
+
133
+ // ─── Remove stop ───
134
+
135
+ const handleRemove = useCallback(
136
+ (index: number, e: React.MouseEvent) => {
137
+ e.preventDefault();
138
+ e.stopPropagation();
139
+ if (stops.length <= 2) return; // min 2 stops
140
+ const newStops = stops.filter((_, i) => i !== index);
141
+ onChange(newStops);
142
+ onSelect(Math.min(index, newStops.length - 1));
143
+ },
144
+ [stops, onChange, onSelect]
145
+ );
146
+
147
+ const handleKeyDown = useCallback(
148
+ (e: React.KeyboardEvent) => {
149
+ if ((e.key === "Delete" || e.key === "Backspace") && stops.length > 2) {
150
+ e.preventDefault();
151
+ const newStops = stops.filter((_, i) => i !== selectedIndex);
152
+ onChange(newStops);
153
+ onSelect(Math.min(selectedIndex, newStops.length - 1));
154
+ }
155
+ // Arrow keys to move selected stop
156
+ if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
157
+ e.preventDefault();
158
+ const step = e.shiftKey ? 5 : 1;
159
+ const dir = e.key === "ArrowRight" ? 1 : -1;
160
+ const updated = stops.map((s, i) =>
161
+ i === selectedIndex
162
+ ? { ...s, position: clamp(s.position + dir * step, 0, 100) }
163
+ : s
164
+ );
165
+ onChange(updated);
166
+ }
167
+ // Tab between stops
168
+ if (e.key === "Tab") {
169
+ e.preventDefault();
170
+ const next = e.shiftKey
171
+ ? (selectedIndex - 1 + stops.length) % stops.length
172
+ : (selectedIndex + 1) % stops.length;
173
+ onSelect(next);
174
+ }
175
+ },
176
+ [stops, selectedIndex, onChange, onSelect]
177
+ );
178
+
179
+ return (
180
+ <div
181
+ className="relative w-full h-6 mb-4"
182
+ role="group"
183
+ aria-label="Gradient color stops"
184
+ tabIndex={0}
185
+ onKeyDown={handleKeyDown}
186
+ >
187
+ {/* Gradient track */}
188
+ <div
189
+ ref={barRef}
190
+ className="absolute top-[5px] w-full h-3.5 rounded-lg cursor-pointer border border-neutral-200"
191
+ style={{ backgroundImage: gradientCSS }}
192
+ onClick={handleBarClick}
193
+ />
194
+
195
+ {/* Stop handles */}
196
+ {stops.map((stop, i) => (
197
+ <div
198
+ key={i}
199
+ data-stop
200
+ className={`absolute rounded-full border-[2.5px] border-white cursor-grab z-[2] transition-shadow ${
201
+ i === selectedIndex
202
+ ? "w-5 h-5 top-[2px] shadow-[0_0_0_2px_rgba(0,0,0,0.15),0_2px_8px_rgba(0,0,0,0.25)]"
203
+ : "w-4 h-4 top-1 shadow-[0_1px_4px_rgba(0,0,0,0.25)]"
204
+ }`}
205
+ style={{
206
+ left: `${stop.position}%`,
207
+ transform: "translateX(-50%)",
208
+ backgroundColor: hexToRgba(stop.color, stop.alpha),
209
+ }}
210
+ onMouseDown={(e) => handleStopMouseDown(i, e)}
211
+ onContextMenu={(e) => handleRemove(i, e)}
212
+ role="slider"
213
+ aria-label={`Color stop ${i + 1}`}
214
+ aria-valuemin={0}
215
+ aria-valuemax={100}
216
+ aria-valuenow={stop.position}
217
+ aria-valuetext={`${stop.color} at ${stop.position}%`}
218
+ />
219
+ ))}
220
+ </div>
221
+ );
222
+ }