@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
|
@@ -66,6 +66,8 @@ export default function ProjectGridBlockRenderer({
|
|
|
66
66
|
const containerTopRef = useRef<number | null>(null);
|
|
67
67
|
const [containerWidth, setContainerWidth] = useState(0);
|
|
68
68
|
const [resolvedProjects, setResolvedProjects] = useState<ResolvedProject[]>([]);
|
|
69
|
+
// BLK-005: Track fetch errors so we can provide user feedback
|
|
70
|
+
const [fetchError, setFetchError] = useState(false);
|
|
69
71
|
|
|
70
72
|
// ─── Measure container width via ResizeObserver ───
|
|
71
73
|
// Uses callback ref so the observer is set up the instant the DOM node
|
|
@@ -116,8 +118,9 @@ export default function ProjectGridBlockRenderer({
|
|
|
116
118
|
|
|
117
119
|
const slugs = block.projects.map((p) => p.project_slug);
|
|
118
120
|
|
|
121
|
+
setFetchError(false);
|
|
119
122
|
fetch("/api/projects")
|
|
120
|
-
.then((r) => (r.ok ? r.json() :
|
|
123
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`API ${r.status}`))))
|
|
121
124
|
.then((data) => {
|
|
122
125
|
const projectMap = new Map<string, { title: string; subtitle: string; thumbnail_path?: string; cover_video?: string }>();
|
|
123
126
|
for (const proj of data.projects || []) {
|
|
@@ -151,8 +154,10 @@ export default function ProjectGridBlockRenderer({
|
|
|
151
154
|
|
|
152
155
|
setResolvedProjects(resolved);
|
|
153
156
|
})
|
|
154
|
-
.catch(() => {
|
|
157
|
+
.catch((err) => {
|
|
158
|
+
console.error("[ProjectGridBlock] Failed to fetch projects:", err);
|
|
155
159
|
setResolvedProjects([]);
|
|
160
|
+
setFetchError(true);
|
|
156
161
|
});
|
|
157
162
|
}, [block.projects]);
|
|
158
163
|
|
|
@@ -270,7 +275,17 @@ export default function ProjectGridBlockRenderer({
|
|
|
270
275
|
return indices;
|
|
271
276
|
}, [entranceEnabled, masonry.items, aboveFoldKeys, gapV]);
|
|
272
277
|
|
|
273
|
-
if (resolvedProjects.length === 0)
|
|
278
|
+
if (resolvedProjects.length === 0) {
|
|
279
|
+
// BLK-005: Show subtle error message if fetch failed (not just empty data)
|
|
280
|
+
if (fetchError) {
|
|
281
|
+
return (
|
|
282
|
+
<div className="py-8 text-center font-sans text-sm text-brand-muted opacity-60">
|
|
283
|
+
Unable to load projects. Please try refreshing the page.
|
|
284
|
+
</div>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
274
289
|
|
|
275
290
|
return (
|
|
276
291
|
<div
|
|
@@ -16,6 +16,8 @@ import { resolveEnterAnimation } from "../../lib/animation/enter-resolve";
|
|
|
16
16
|
import BlockRenderer from "./BlockRenderer";
|
|
17
17
|
import EnterAnimationWrapper from "./EnterAnimationWrapper";
|
|
18
18
|
import { getRowLayoutStyles, hexToRgba } from "../../lib/builder/layout-styles";
|
|
19
|
+
import { colorToOverrideRule, borderColorToOverrideRule, parseColorField } from "../../lib/color-utils";
|
|
20
|
+
import type { ColorField } from "../../lib/sanity/types";
|
|
19
21
|
import { BREAKPOINTS } from "../../lib/builder/constants";
|
|
20
22
|
|
|
21
23
|
/**
|
|
@@ -81,9 +83,9 @@ function buildSectionResponsiveCss(section: PageSection): string | null {
|
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
85
|
|
|
84
|
-
// Border color
|
|
86
|
+
// Border color (supports solid + gradients via ColorField bridge)
|
|
85
87
|
if (overrides.border_color) {
|
|
86
|
-
rules.push(
|
|
88
|
+
rules.push(borderColorToOverrideRule(parseColorField(overrides.border_color)));
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
// Border style
|
|
@@ -91,14 +93,13 @@ function buildSectionResponsiveCss(section: PageSection): string | null {
|
|
|
91
93
|
rules.push(`border-style:${overrides.border_style}!important`);
|
|
92
94
|
}
|
|
93
95
|
|
|
94
|
-
// Background color + opacity
|
|
96
|
+
// Background color + opacity (gradient-safe via ColorField bridge)
|
|
95
97
|
if (overrides.background_color) {
|
|
96
98
|
const opacity = overrides.background_opacity as number | undefined;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
99
|
+
rules.push(colorToOverrideRule(
|
|
100
|
+
parseColorField(overrides.background_color),
|
|
101
|
+
opacity
|
|
102
|
+
));
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
// Background image + sub-properties
|
|
@@ -25,6 +25,7 @@ import { resolveEnterAnimation } from "../../lib/animation/enter-resolve";
|
|
|
25
25
|
import BlockRenderer from "./BlockRenderer";
|
|
26
26
|
import EnterAnimationWrapper from "./EnterAnimationWrapper";
|
|
27
27
|
import { getRowLayoutStyles, getBlockAlignmentStyles, hasBlockAlignment, getColumnVerticalAlign, hexToRgba } from "../../lib/builder/layout-styles";
|
|
28
|
+
import { parseColorField, colorToOverrideRule, borderColorToOverrideRule } from "../../lib/color-utils";
|
|
28
29
|
import { BREAKPOINTS } from "../../lib/builder/constants";
|
|
29
30
|
|
|
30
31
|
// ── Responsive CSS generation ──
|
|
@@ -99,9 +100,9 @@ function buildSettingsOverrideRules(
|
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
// Border color
|
|
103
|
+
// Border color (supports solid + gradients via ColorField bridge)
|
|
103
104
|
if (overrides.border_color) {
|
|
104
|
-
rules.push(
|
|
105
|
+
rules.push(borderColorToOverrideRule(parseColorField(overrides.border_color)));
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
// Border style
|
|
@@ -109,14 +110,13 @@ function buildSettingsOverrideRules(
|
|
|
109
110
|
rules.push(`border-style:${overrides.border_style}!important`);
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
// Background color + opacity
|
|
113
|
+
// Background color + opacity (supports solid + gradients via ColorField bridge)
|
|
113
114
|
if (overrides.background_color) {
|
|
114
115
|
const opacity = overrides.background_opacity;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
116
|
+
rules.push(colorToOverrideRule(
|
|
117
|
+
parseColorField(overrides.background_color),
|
|
118
|
+
opacity
|
|
119
|
+
));
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
return rules;
|
|
@@ -8,8 +8,10 @@ const heightMap: Record<string, string> = {
|
|
|
8
8
|
};
|
|
9
9
|
|
|
10
10
|
export default function SpacerBlockRenderer({ block }: { block: SpacerBlock }) {
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// BLK-007: Use != null instead of truthiness to allow custom_height of 0
|
|
12
|
+
if (block.height === "custom" && block.custom_height != null) {
|
|
13
|
+
const h = Math.max(0, block.custom_height);
|
|
14
|
+
return <div style={{ height: `${h}px` }} aria-hidden />;
|
|
13
15
|
}
|
|
14
16
|
return (
|
|
15
17
|
<div className={heightMap[block.height ?? "medium"]} aria-hidden />
|
|
@@ -3,7 +3,8 @@ import { PortableText } from "next-sanity";
|
|
|
3
3
|
|
|
4
4
|
/** Resolve fontSize: supports numeric px and legacy string enum */
|
|
5
5
|
function resolvePublicFontSize(fontSize?: number | string): string | undefined {
|
|
6
|
-
|
|
6
|
+
// BLK-015: Guard against negative or zero font sizes
|
|
7
|
+
if (typeof fontSize === "number") return fontSize > 0 ? `${fontSize}px` : undefined;
|
|
7
8
|
// Legacy Tailwind class mapping
|
|
8
9
|
const tailwindMap: Record<string, string> = {
|
|
9
10
|
small: "text-sm",
|
|
@@ -49,7 +50,7 @@ export function getTextBlockStyles(block: TextBlock): { className: string; style
|
|
|
49
50
|
const isNumericWeight = s?.fontWeight && !isNaN(parseInt(s.fontWeight, 10));
|
|
50
51
|
|
|
51
52
|
const classes = [
|
|
52
|
-
"font-
|
|
53
|
+
"font-sans",
|
|
53
54
|
!isNumericFontSize ? resolvePublicFontSize(s?.fontSize) : undefined,
|
|
54
55
|
alignmentMap[s?.alignment ?? "left"],
|
|
55
56
|
!isNumericWeight ? resolvePublicFontWeight(s?.fontWeight) : undefined,
|
|
@@ -58,9 +59,13 @@ export function getTextBlockStyles(block: TextBlock): { className: string; style
|
|
|
58
59
|
.join(" ");
|
|
59
60
|
|
|
60
61
|
const inlineStyle: React.CSSProperties = {};
|
|
61
|
-
if (isNumericFontSize) inlineStyle.fontSize = `${s!.fontSize}px`;
|
|
62
|
+
if (isNumericFontSize && (s!.fontSize as number) > 0) inlineStyle.fontSize = `${s!.fontSize}px`;
|
|
62
63
|
if (isNumericWeight) inlineStyle.fontWeight = parseInt(s!.fontWeight!, 10);
|
|
63
|
-
|
|
64
|
+
// Guard: text color must be a solid hex string, not a gradient.
|
|
65
|
+
// resolveColorHex extracts a representative hex if a gradient slips through.
|
|
66
|
+
if (s?.color) {
|
|
67
|
+
inlineStyle.color = typeof s.color === "string" ? s.color : "#000000";
|
|
68
|
+
}
|
|
64
69
|
if (s?.lineHeight) inlineStyle.lineHeight = s.lineHeight;
|
|
65
70
|
if (s?.letterSpacing) inlineStyle.letterSpacing = s.letterSpacing;
|
|
66
71
|
if (s?.maxWidth) inlineStyle.maxWidth = s.maxWidth;
|
|
@@ -70,9 +70,15 @@ export default function BuilderCanvas({ children }: BuilderCanvasProps) {
|
|
|
70
70
|
const zoomRef = useRef(zoom);
|
|
71
71
|
const panXRef = useRef(panX);
|
|
72
72
|
const panYRef = useRef(panY);
|
|
73
|
+
// LEAK-002 fix: Also ref action functions so flushWheel/handleWheel
|
|
74
|
+
// callbacks are truly stable and never cause wheel listener resubscription.
|
|
75
|
+
const zoomToPointRef = useRef(zoomToPoint);
|
|
76
|
+
const setCanvasPanRef = useRef(setCanvasPan);
|
|
73
77
|
useEffect(() => { zoomRef.current = zoom; }, [zoom]);
|
|
74
78
|
useEffect(() => { panXRef.current = panX; }, [panX]);
|
|
75
79
|
useEffect(() => { panYRef.current = panY; }, [panY]);
|
|
80
|
+
useEffect(() => { zoomToPointRef.current = zoomToPoint; }, [zoomToPoint]);
|
|
81
|
+
useEffect(() => { setCanvasPanRef.current = setCanvasPan; }, [setCanvasPan]);
|
|
76
82
|
|
|
77
83
|
// ---- Trigger smooth animation for programmatic zoom/pan ----
|
|
78
84
|
const triggerAnimation = useCallback(() => {
|
|
@@ -128,7 +134,7 @@ export default function BuilderCanvas({ children }: BuilderCanvasProps) {
|
|
|
128
134
|
// Throttled via requestAnimationFrame to prevent jank on rapid scroll.
|
|
129
135
|
// Deltas accumulate between frames so no input is lost.
|
|
130
136
|
|
|
131
|
-
// Stable flush — reads current values from refs, never re-created
|
|
137
|
+
// Stable flush — reads current values from refs, never re-created (LEAK-002)
|
|
132
138
|
const flushWheel = useCallback(() => {
|
|
133
139
|
wheelRafRef.current = null;
|
|
134
140
|
const accum = wheelAccumRef.current;
|
|
@@ -138,11 +144,11 @@ export default function BuilderCanvas({ children }: BuilderCanvasProps) {
|
|
|
138
144
|
if (accum.isZoom) {
|
|
139
145
|
const zoomDelta = -accum.deltaY * 0.005;
|
|
140
146
|
const newZoom = zoomRef.current * (1 + zoomDelta);
|
|
141
|
-
|
|
147
|
+
zoomToPointRef.current(newZoom, accum.cursorX, accum.cursorY);
|
|
142
148
|
} else {
|
|
143
|
-
|
|
149
|
+
setCanvasPanRef.current(panXRef.current - accum.deltaX, panYRef.current - accum.deltaY);
|
|
144
150
|
}
|
|
145
|
-
}, [
|
|
151
|
+
}, []); // No deps — all values read from refs
|
|
146
152
|
|
|
147
153
|
// Stable wheel handler — never re-subscribed during zoom/pan
|
|
148
154
|
const handleWheel = useCallback(
|
|
@@ -1,243 +1,51 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
color
|
|
25
|
-
onChange
|
|
26
|
-
onClose
|
|
27
|
-
confirmLabel
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
|
|
53
|
-
const s = Math.round(x * 100);
|
|
54
|
-
const v = Math.round((1 - y) * 100);
|
|
55
|
-
setSat(s);
|
|
56
|
-
setVal(v);
|
|
57
|
-
updateHex(hue, s, v);
|
|
58
|
-
}, [hue, updateHex]);
|
|
59
|
-
|
|
60
|
-
// Hue slider interaction
|
|
61
|
-
const handleHueMove = useCallback((e: React.MouseEvent | MouseEvent) => {
|
|
62
|
-
const rect = hueRef.current?.getBoundingClientRect();
|
|
63
|
-
if (!rect) return;
|
|
64
|
-
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
65
|
-
const h = Math.round(x * 360);
|
|
66
|
-
setHue(h);
|
|
67
|
-
updateHex(h, sat, val);
|
|
68
|
-
}, [sat, val, updateHex]);
|
|
69
|
-
|
|
70
|
-
// Global mouse up
|
|
71
|
-
useEffect(() => {
|
|
72
|
-
const handleUp = () => {
|
|
73
|
-
draggingCanvas.current = false;
|
|
74
|
-
draggingHue.current = false;
|
|
75
|
-
};
|
|
76
|
-
const handleMove = (e: MouseEvent) => {
|
|
77
|
-
if (draggingCanvas.current) handleCanvasMove(e);
|
|
78
|
-
if (draggingHue.current) handleHueMove(e);
|
|
79
|
-
};
|
|
80
|
-
window.addEventListener("mouseup", handleUp);
|
|
81
|
-
window.addEventListener("mousemove", handleMove);
|
|
82
|
-
return () => {
|
|
83
|
-
window.removeEventListener("mouseup", handleUp);
|
|
84
|
-
window.removeEventListener("mousemove", handleMove);
|
|
85
|
-
};
|
|
86
|
-
}, [handleCanvasMove, handleHueMove]);
|
|
87
|
-
|
|
88
|
-
const handleHexInput = (v: string) => {
|
|
89
|
-
setHex(v);
|
|
90
|
-
if (isValidHex(v)) {
|
|
91
|
-
const hsv = hexToHSV(v);
|
|
92
|
-
setHue(hsv.h);
|
|
93
|
-
setSat(hsv.s);
|
|
94
|
-
setVal(hsv.v);
|
|
95
|
-
if (compact) onChange(v);
|
|
96
|
-
}
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
// Keyboard handlers for canvas (saturation/value) and hue slider
|
|
100
|
-
const handleCanvasKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
101
|
-
const step = e.shiftKey ? 10 : 2;
|
|
102
|
-
let newSat = sat;
|
|
103
|
-
let newVal = val;
|
|
104
|
-
switch (e.key) {
|
|
105
|
-
case "ArrowRight": newSat = Math.min(100, sat + step); break;
|
|
106
|
-
case "ArrowLeft": newSat = Math.max(0, sat - step); break;
|
|
107
|
-
case "ArrowUp": newVal = Math.min(100, val + step); break;
|
|
108
|
-
case "ArrowDown": newVal = Math.max(0, val - step); break;
|
|
109
|
-
default: return;
|
|
110
|
-
}
|
|
111
|
-
e.preventDefault();
|
|
112
|
-
setSat(newSat);
|
|
113
|
-
setVal(newVal);
|
|
114
|
-
updateHex(hue, newSat, newVal);
|
|
115
|
-
}, [sat, val, hue, updateHex]);
|
|
116
|
-
|
|
117
|
-
const handleHueKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
118
|
-
const step = e.shiftKey ? 20 : 5;
|
|
119
|
-
let newHue = hue;
|
|
120
|
-
switch (e.key) {
|
|
121
|
-
case "ArrowRight": newHue = Math.min(360, hue + step); break;
|
|
122
|
-
case "ArrowLeft": newHue = Math.max(0, hue - step); break;
|
|
123
|
-
default: return;
|
|
124
|
-
}
|
|
125
|
-
e.preventDefault();
|
|
126
|
-
setHue(newHue);
|
|
127
|
-
updateHex(newHue, sat, val);
|
|
128
|
-
}, [hue, sat, val, updateHex]);
|
|
129
|
-
|
|
130
|
-
const r = parseInt(hex.slice(1, 3), 16) || 0;
|
|
131
|
-
const g = parseInt(hex.slice(3, 5), 16) || 0;
|
|
132
|
-
const b = parseInt(hex.slice(5, 7), 16) || 0;
|
|
133
|
-
|
|
134
|
-
return (
|
|
135
|
-
<div className="bg-white rounded-2xl p-4 w-[260px] shadow-xl border border-neutral-200">
|
|
136
|
-
{/* Saturation/Value canvas */}
|
|
137
|
-
<div
|
|
138
|
-
ref={canvasRef}
|
|
139
|
-
role="slider"
|
|
140
|
-
tabIndex={0}
|
|
141
|
-
aria-label="Color saturation and brightness"
|
|
142
|
-
aria-valuetext={`Saturation ${sat}%, Brightness ${val}%`}
|
|
143
|
-
className="w-full h-[160px] rounded-xl cursor-crosshair relative select-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#076bff]"
|
|
144
|
-
style={{
|
|
145
|
-
background: `linear-gradient(to bottom, transparent, #000), linear-gradient(to right, #fff, hsl(${hue}, 100%, 50%))`,
|
|
146
|
-
}}
|
|
147
|
-
onMouseDown={(e) => { draggingCanvas.current = true; handleCanvasMove(e); }}
|
|
148
|
-
onKeyDown={handleCanvasKeyDown}
|
|
149
|
-
>
|
|
150
|
-
<div
|
|
151
|
-
className="absolute w-4 h-4 rounded-full border-2 border-white pointer-events-none"
|
|
152
|
-
style={{
|
|
153
|
-
left: `${sat}%`,
|
|
154
|
-
top: `${100 - val}%`,
|
|
155
|
-
transform: "translate(-50%, -50%)",
|
|
156
|
-
boxShadow: "0 0 0 1px rgba(0,0,0,0.3), 0 2px 6px rgba(0,0,0,0.3)",
|
|
157
|
-
}}
|
|
158
|
-
/>
|
|
159
|
-
</div>
|
|
160
|
-
|
|
161
|
-
{/* Hue slider */}
|
|
162
|
-
<div
|
|
163
|
-
ref={hueRef}
|
|
164
|
-
role="slider"
|
|
165
|
-
tabIndex={0}
|
|
166
|
-
aria-label="Color hue"
|
|
167
|
-
aria-valuemin={0}
|
|
168
|
-
aria-valuemax={360}
|
|
169
|
-
aria-valuenow={hue}
|
|
170
|
-
aria-valuetext={`Hue ${hue} degrees`}
|
|
171
|
-
className="w-full h-3.5 rounded-full mt-3 cursor-pointer relative select-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#076bff]"
|
|
172
|
-
style={{
|
|
173
|
-
background: "linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)",
|
|
174
|
-
}}
|
|
175
|
-
onMouseDown={(e) => { draggingHue.current = true; handleHueMove(e); }}
|
|
176
|
-
onKeyDown={handleHueKeyDown}
|
|
177
|
-
>
|
|
178
|
-
<div
|
|
179
|
-
className="absolute w-4 h-4 rounded-full border-2 border-white pointer-events-none"
|
|
180
|
-
style={{
|
|
181
|
-
left: `${(hue / 360) * 100}%`,
|
|
182
|
-
top: "50%",
|
|
183
|
-
transform: "translate(-50%, -50%)",
|
|
184
|
-
background: `hsl(${hue}, 100%, 50%)`,
|
|
185
|
-
boxShadow: "0 1px 3px rgba(0,0,0,0.4)",
|
|
186
|
-
}}
|
|
187
|
-
/>
|
|
188
|
-
</div>
|
|
189
|
-
|
|
190
|
-
{/* Hex input */}
|
|
191
|
-
<div className="flex items-center gap-2.5 mt-3.5">
|
|
192
|
-
<div
|
|
193
|
-
className="w-8 h-8 rounded-lg border border-neutral-200 shrink-0"
|
|
194
|
-
style={{ background: hex }}
|
|
195
|
-
/>
|
|
196
|
-
<input
|
|
197
|
-
value={hex.toUpperCase()}
|
|
198
|
-
onChange={(e) => handleHexInput(e.target.value)}
|
|
199
|
-
className="flex-1 bg-neutral-50 border border-neutral-200 rounded-lg px-2.5 py-1.5 text-neutral-900 text-xs font-mono outline-none focus:border-[#076bff] focus:ring-2 focus:ring-[#076bff]/10 transition-colors"
|
|
200
|
-
/>
|
|
201
|
-
</div>
|
|
202
|
-
|
|
203
|
-
{/* RGB readout */}
|
|
204
|
-
<div className="flex gap-2 mt-2.5">
|
|
205
|
-
{[
|
|
206
|
-
{ label: "R", val: r },
|
|
207
|
-
{ label: "G", val: g },
|
|
208
|
-
{ label: "B", val: b },
|
|
209
|
-
].map(({ label, val: v }) => (
|
|
210
|
-
<div key={label} className="flex-1">
|
|
211
|
-
<div className="text-[9px] text-neutral-400 uppercase tracking-widest mb-0.5">{label}</div>
|
|
212
|
-
<div className="bg-neutral-50 border border-neutral-200 rounded-md px-2 py-1 text-neutral-600 text-[11px] text-center font-mono">
|
|
213
|
-
{v}
|
|
214
|
-
</div>
|
|
215
|
-
</div>
|
|
216
|
-
))}
|
|
217
|
-
</div>
|
|
218
|
-
|
|
219
|
-
{/* Buttons (non-compact mode) */}
|
|
220
|
-
{!compact && (
|
|
221
|
-
<div className="flex gap-2 mt-3.5">
|
|
222
|
-
{onClose && (
|
|
223
|
-
<button
|
|
224
|
-
onClick={onClose}
|
|
225
|
-
className="flex-1 py-2 rounded-lg border border-neutral-200 bg-transparent text-neutral-500 text-xs font-mono cursor-pointer hover:border-neutral-300 hover:text-neutral-700 transition-colors"
|
|
226
|
-
>
|
|
227
|
-
Cancel
|
|
228
|
-
</button>
|
|
229
|
-
)}
|
|
230
|
-
<button
|
|
231
|
-
onClick={() => { onChange(hex); onClose?.(); }}
|
|
232
|
-
className="flex-1 py-2 rounded-lg border-none bg-[#076bff] text-white text-xs font-mono font-semibold cursor-pointer hover:bg-[#0559d4] transition-colors"
|
|
233
|
-
>
|
|
234
|
-
{confirmLabel}
|
|
235
|
-
</button>
|
|
236
|
-
</div>
|
|
237
|
-
)}
|
|
238
|
-
</div>
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// ─── Export helpers ───
|
|
243
|
-
export { isValidHex, hexToHSL, hexToHSV, hsvToHex };
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @deprecated Use `UnifiedColorPicker` from `./color-picker` instead.
|
|
5
|
+
*
|
|
6
|
+
* This file is kept for backward compatibility. It re-exports the new
|
|
7
|
+
* UnifiedColorPicker with an adapter that maps the old props to the new API.
|
|
8
|
+
*
|
|
9
|
+
* Old API: ColorPicker({ color, onChange, onClose, confirmLabel, compact })
|
|
10
|
+
* New API: UnifiedColorPicker({ value, onChange, onClose, confirmLabel, ... })
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { UnifiedColorPicker } from "./color-picker";
|
|
14
|
+
import { isValidHex } from "../../lib/color-utils";
|
|
15
|
+
|
|
16
|
+
// Re-export helpers for any consumers that imported from here
|
|
17
|
+
export { isValidHex } from "../../lib/color-utils";
|
|
18
|
+
export { hexToHSL, hexToHSV, hsvToHex } from "../../lib/color-utils";
|
|
19
|
+
|
|
20
|
+
// ─── Legacy ColorPicker Adapter ───
|
|
21
|
+
|
|
22
|
+
interface ColorPickerProps {
|
|
23
|
+
/** @deprecated Use `value` instead */
|
|
24
|
+
color: string;
|
|
25
|
+
onChange: (hex: string) => void;
|
|
26
|
+
onClose?: () => void;
|
|
27
|
+
confirmLabel?: string;
|
|
28
|
+
/** @deprecated Compact mode is no longer supported. Ignored. */
|
|
29
|
+
compact?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @deprecated Use `import { UnifiedColorPicker } from "./color-picker"` instead.
|
|
34
|
+
*/
|
|
35
|
+
export default function ColorPicker({
|
|
36
|
+
color,
|
|
37
|
+
onChange,
|
|
38
|
+
onClose,
|
|
39
|
+
confirmLabel = "Confirm",
|
|
40
|
+
}: ColorPickerProps) {
|
|
41
|
+
const handleClose = onClose ?? (() => {});
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<UnifiedColorPicker
|
|
45
|
+
value={isValidHex(color) ? color : "#ffffff"}
|
|
46
|
+
onChange={(val) => onChange(typeof val === "string" ? val : "#000000")}
|
|
47
|
+
onClose={handleClose}
|
|
48
|
+
confirmLabel={confirmLabel}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
}
|