Skip to content

Commit 7410d6c

Browse files
committed
feat: custom color picker
1 parent c8c4622 commit 7410d6c

File tree

3 files changed

+277
-43
lines changed

3 files changed

+277
-43
lines changed

public/banner.piml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
(from) 2025-12-21T00:00:00Z
66
(to) 2025-12-23T23:59:59Z
77
(text) NEURAL NET ONLINE: THE 3D KNOWLEDGE GRAPH VISUALIZATION PROTOCOL HAS BEEN DEPLOYED. ACCESS AT /GRAPH.
8-
(isActive) true
8+
(isActive) false
99
(link) /graph
1010
(linkText) See Neural Net
1111

@@ -15,7 +15,16 @@
1515
(from) 2025-12-23T00:00:00Z
1616
(to) 2025-12-25T23:59:59Z
1717
(text) LOGIC ARCHITECT V2 IS ONLINE: THE PERFECT LOGIC GATE EDITORS. ACCESS AT /APPS/LOGIC-ARCHITECT.
18-
(isActive) true
18+
(isActive) false
1919
(link) /apps/logic-architect
2020
(linkText) See Logic Architect
2121

22+
> (banner)
23+
(id) magaziner-v1
24+
(type) info
25+
(from) 2025-12-25T00:00:00Z
26+
(to) 2025-12-30T23:59:59Z
27+
(text) MAGAZINER V1 IS ONLINE: THE PREMIUM MAGAZINE COVER CONSTRUCTOR PROTOCOL. ACCESS AT /APPS/MAGAZINER.
28+
(isActive) true
29+
(link) /apps/magaziner
30+
(linkText) See Magaziner
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import React, { useState, useRef, useEffect } from 'react';
2+
import { motion, AnimatePresence } from 'framer-motion';
3+
import { EyedropperIcon, XIcon } from '@phosphor-icons/react';
4+
5+
const PRESET_COLORS = [
6+
{ name: 'Pure Void', hex: '#050505' },
7+
{ name: 'Paper White', hex: '#F5F5F5' },
8+
{ name: 'Emerald Flux', hex: '#10b981' },
9+
{ name: 'Salmon Signal', hex: '#FA8072' },
10+
{ name: 'Cyber Cyan', hex: '#00FFFF' },
11+
{ name: 'Neon Violet', hex: '#a855f7' },
12+
{ name: 'Amber Warning', hex: '#f59e0b' },
13+
{ name: 'Royal Gold', hex: '#D4AF37' },
14+
{ name: 'Crimson Data', hex: '#ef4444' },
15+
{ name: 'Cobalt Core', hex: '#3b82f6' },
16+
{ name: 'Deep Slate', hex: '#1e293b' },
17+
{ name: 'Ghost Gray', hex: '#94a3b8' },
18+
];
19+
20+
const hexToHsv = (hex) => {
21+
let r = 0, g = 0, b = 0;
22+
if (hex.length === 4) {
23+
r = parseInt(hex[1] + hex[1], 16);
24+
g = parseInt(hex[2] + hex[2], 16);
25+
b = parseInt(hex[3] + hex[3], 16);
26+
} else if (hex.length === 7) {
27+
r = parseInt(hex.substring(1, 3), 16);
28+
g = parseInt(hex.substring(3, 5), 16);
29+
b = parseInt(hex.substring(5, 7), 16);
30+
}
31+
32+
r /= 255; g /= 255; b /= 255;
33+
const max = Math.max(r, g, b), min = Math.min(r, g, b);
34+
let h, s, v = max;
35+
const d = max - min;
36+
s = max === 0 ? 0 : d / max;
37+
38+
if (max === min) {
39+
h = 0;
40+
} else {
41+
switch (max) {
42+
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
43+
case g: h = (b - r) / d + 2; break;
44+
case b: h = (r - g) / d + 4; break;
45+
default: break;
46+
}
47+
h /= 6;
48+
}
49+
return { h, s, v };
50+
};
51+
52+
const hsvToHex = (h, s, v) => {
53+
let r, g, b;
54+
const i = Math.floor(h * 6);
55+
const f = h * 6 - i;
56+
const p = v * (1 - s);
57+
const q = v * (1 - f * s);
58+
const t = v * (1 - (1 - f) * s);
59+
switch (i % 6) {
60+
case 0: r = v; g = t; b = p; break;
61+
case 1: r = q; g = v; b = p; break;
62+
case 2: r = p; g = v; b = t; break;
63+
case 3: r = p; g = q; b = v; break;
64+
case 4: r = t; g = p; b = v; break;
65+
case 5: r = v; g = p; b = q; break;
66+
default: break;
67+
}
68+
const toHex = (x) => Math.round(x * 255).toString(16).padStart(2, '0');
69+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
70+
};
71+
72+
const CustomColorPicker = ({ value, onChange, label }) => {
73+
const [isOpen, setIsOpen] = useState(false);
74+
const [hsv, setHsv] = useState({ h: 0, s: 0, v: 0 });
75+
const [inputValue, setInputValue] = useState(value);
76+
const containerRef = useRef(null);
77+
const satRef = useRef(null);
78+
const hueRef = useRef(null);
79+
80+
useEffect(() => {
81+
try {
82+
setHsv(hexToHsv(value));
83+
setInputValue(value);
84+
} catch (e) {
85+
setHsv({ h: 0, s: 0, v: 0 });
86+
}
87+
}, [value]);
88+
89+
useEffect(() => {
90+
const handleClickOutside = (event) => {
91+
if (containerRef.current && !containerRef.current.contains(event.target)) {
92+
setIsOpen(false);
93+
}
94+
};
95+
document.addEventListener('mousedown', handleClickOutside);
96+
return () => document.removeEventListener('mousedown', handleClickOutside);
97+
}, []);
98+
99+
const handleSatMouseDown = (e) => {
100+
const handleMouseMove = (moveEvent) => {
101+
const rect = satRef.current.getBoundingClientRect();
102+
let s = (moveEvent.clientX - rect.left) / rect.width;
103+
let v = 1 - (moveEvent.clientY - rect.top) / rect.height;
104+
s = Math.max(0, Math.min(1, s));
105+
v = Math.max(0, Math.min(1, v));
106+
onChange(hsvToHex(hsv.h, s, v));
107+
};
108+
109+
const handleMouseUp = () => {
110+
window.removeEventListener('mousemove', handleMouseMove);
111+
window.removeEventListener('mouseup', handleMouseUp);
112+
};
113+
114+
window.addEventListener('mousemove', handleMouseMove);
115+
window.addEventListener('mouseup', handleMouseUp);
116+
handleMouseMove(e);
117+
};
118+
119+
const handleHueMouseDown = (e) => {
120+
const handleMouseMove = (moveEvent) => {
121+
const rect = hueRef.current.getBoundingClientRect();
122+
let h = (moveEvent.clientX - rect.left) / rect.width;
123+
h = Math.max(0, Math.min(1, h));
124+
onChange(hsvToHex(h, hsv.s, hsv.v));
125+
};
126+
127+
const handleMouseUp = () => {
128+
window.removeEventListener('mousemove', handleMouseMove);
129+
window.removeEventListener('mouseup', handleMouseUp);
130+
};
131+
132+
window.addEventListener('mousemove', handleMouseMove);
133+
window.addEventListener('mouseup', handleMouseUp);
134+
handleMouseMove(e);
135+
};
136+
137+
return (
138+
<div className="space-y-2" ref={containerRef}>
139+
{label && (
140+
<label className="block font-mono text-[9px] uppercase text-gray-600 tracking-widest">
141+
{label}
142+
</label>
143+
)}
144+
145+
<div className="relative">
146+
<button
147+
onClick={() => setIsOpen(!isOpen)}
148+
className="w-full flex items-center gap-3 p-2 bg-black/40 border border-white/10 hover:border-white/30 transition-all rounded-sm group"
149+
>
150+
<div
151+
className="w-6 h-6 rounded-sm border border-white/20 shadow-inner shrink-0"
152+
style={{ backgroundColor: value }}
153+
/>
154+
<span className="font-mono text-[10px] uppercase tracking-widest text-gray-400 group-hover:text-white transition-colors">
155+
{value.toUpperCase()}
156+
</span>
157+
<EyedropperIcon className="ml-auto text-gray-600 group-hover:text-emerald-500 transition-colors" size={14} />
158+
</button>
159+
160+
<AnimatePresence>
161+
{isOpen && (
162+
<motion.div
163+
initial={{ opacity: 0, y: 5, scale: 0.95 }}
164+
animate={{ opacity: 1, y: 0, scale: 1 }}
165+
exit={{ opacity: 0, y: 5, scale: 0.95 }}
166+
className="absolute z-50 top-full mt-2 w-64 bg-[#0a0a0a] border border-white/20 p-4 shadow-[0_20px_50px_rgba(0,0,0,0.8)] rounded-sm"
167+
>
168+
<div className="flex items-center justify-between mb-4 pb-2 border-b border-white/5">
169+
<span className="font-mono text-[9px] font-bold text-emerald-500 uppercase tracking-widest">
170+
Color_Matrix
171+
</span>
172+
<button onClick={() => setIsOpen(false)} className="text-gray-600 hover:text-white">
173+
<XIcon size={12} />
174+
</button>
175+
</div>
176+
177+
{/* Saturation / Value Picker */}
178+
<div
179+
ref={satRef}
180+
onMouseDown={handleSatMouseDown}
181+
className="relative w-full aspect-video mb-4 cursor-crosshair rounded-sm overflow-hidden border border-white/10"
182+
style={{ backgroundColor: hsvToHex(hsv.h, 1, 1) }}
183+
>
184+
<div className="absolute inset-0 bg-gradient-to-r from-white to-transparent" />
185+
<div className="absolute inset-0 bg-gradient-to-t from-black to-transparent" />
186+
<div
187+
className="absolute w-3 h-3 border-2 border-white rounded-full -translate-x-1/2 -translate-y-1/2 shadow-[0_0_5px_rgba(0,0,0,0.5)] pointer-events-none"
188+
style={{ left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%` }}
189+
/>
190+
</div>
191+
192+
{/* Hue Slider */}
193+
<div
194+
ref={hueRef}
195+
onMouseDown={handleHueMouseDown}
196+
className="relative w-full h-4 mb-6 cursor-pointer rounded-sm border border-white/10"
197+
style={{ background: 'linear-gradient(to right, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%)' }}
198+
>
199+
<div
200+
className="absolute top-0 bottom-0 w-1.5 bg-white border border-black/40 -translate-x-1/2 shadow-md pointer-events-none"
201+
style={{ left: `${hsv.h * 100}%` }}
202+
/>
203+
</div>
204+
205+
<div className="grid grid-cols-6 gap-1.5 mb-4">
206+
{PRESET_COLORS.map((c) => (
207+
<button
208+
key={c.hex}
209+
onClick={() => {
210+
onChange(c.hex);
211+
}}
212+
className={`w-full aspect-square rounded-sm border transition-all ${
213+
value.toLowerCase() === c.hex.toLowerCase()
214+
? 'border-emerald-500 scale-110 z-10'
215+
: 'border-white/10 hover:border-white/40'
216+
}`}
217+
style={{ backgroundColor: c.hex }}
218+
title={c.name}
219+
/>
220+
))}
221+
</div>
222+
223+
<div className="flex items-center gap-2">
224+
<div className="relative w-full bg-black/40 border border-white/10 rounded-sm overflow-hidden flex items-center px-3 h-8">
225+
<input
226+
type="text"
227+
value={inputValue.toUpperCase()}
228+
onChange={(e) => {
229+
const val = e.target.value;
230+
setInputValue(val);
231+
232+
let hex = val;
233+
if (!hex.startsWith('#') && hex.length > 0) hex = '#' + hex;
234+
235+
// Validate 3, 4, 6, or 8 digit hex
236+
if (/^#([0-9A-F]{3}){1,2}$/i.test(hex) || /^#([0-9A-F]{4}){1,2}$/i.test(hex)) {
237+
onChange(hex);
238+
}
239+
}}
240+
onBlur={() => setInputValue(value)}
241+
className="w-full bg-transparent font-mono text-[10px] text-white outline-none uppercase tracking-widest"
242+
/>
243+
<div className="w-4 h-4 rounded-sm border border-white/10 shrink-0" style={{ backgroundColor: value }} />
244+
</div>
245+
</div>
246+
</motion.div>
247+
)}
248+
</AnimatePresence>
249+
</div>
250+
</div>
251+
);
252+
};
253+
254+
export default CustomColorPicker;

src/pages/apps/MagazinerPage.jsx

Lines changed: 12 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { useToast } from '../../hooks/useToast';
1919
import CustomDropdown from '../../components/CustomDropdown';
2020
import CustomSlider from '../../components/CustomSlider';
2121
import BrutalistDialog from '../../components/BrutalistDialog';
22+
import CustomColorPicker from '../../components/CustomColorPicker';
2223

2324
const STYLES = [
2425
{ value: 'brutalist', label: 'BRUTALIST_CHAOS' },
@@ -622,48 +623,18 @@ const MagazinerPage = () => {
622623
)}
623624
</div>
624625

625-
<div className="space-y-4">
626-
<label className="block font-mono text-[9px] uppercase text-gray-600">Base Chromatic</label>
627-
<div className="flex flex-wrap gap-2">
628-
{COLORS.map((c) => (
629-
<button
630-
key={c.name}
631-
onClick={() => setPrimaryColor(c)}
632-
className={`w-8 h-8 rounded-full border-2 ${primaryColor.hex === c.hex ? 'border-emerald-500 scale-110' : 'border-transparent'}`}
633-
style={{ backgroundColor: c.hex }}
634-
title={c.name}
635-
/>
636-
))}
637-
<input
638-
type="color"
639-
value={primaryColor.hex}
640-
onChange={(e) => setPrimaryColor({ name: 'Custom', hex: e.target.value })}
641-
className="w-8 h-8 bg-transparent border-2 border-white/10 rounded-full cursor-pointer p-0 overflow-hidden"
642-
title="Custom Base Color"
643-
/>
644-
</div>
645-
</div>
626+
<div className="space-y-6">
627+
<CustomColorPicker
628+
label="Base Chromatic"
629+
value={primaryColor.hex}
630+
onChange={(hex) => setPrimaryColor({ name: 'Custom', hex })}
631+
/>
646632

647-
<div className="space-y-4">
648-
<label className="block font-mono text-[9px] uppercase text-gray-600">Accent Chromatic</label>
649-
<div className="flex flex-wrap gap-2">
650-
{COLORS.map((c) => (
651-
<button
652-
key={c.name}
653-
onClick={() => setAccentColor(c)}
654-
className={`w-8 h-8 rounded-full border-2 ${accentColor.hex === c.hex ? 'border-emerald-500 scale-110' : 'border-transparent'}`}
655-
style={{ backgroundColor: c.hex }}
656-
title={c.name}
657-
/>
658-
))}
659-
<input
660-
type="color"
661-
value={accentColor.hex}
662-
onChange={(e) => setAccentColor({ name: 'Custom', hex: e.target.value })}
663-
className="w-8 h-8 bg-transparent border-2 border-white/10 rounded-full cursor-pointer p-0 overflow-hidden"
664-
title="Custom Accent Color"
665-
/>
666-
</div>
633+
<CustomColorPicker
634+
label="Accent Chromatic"
635+
value={accentColor.hex}
636+
onChange={(hex) => setAccentColor({ name: 'Custom', hex })}
637+
/>
667638
</div>
668639
</div>
669640
</div>

0 commit comments

Comments
 (0)