1"use client";
2
3import { PlusIcon, XIcon } from "lucide-react";
4import { useCallback, useEffect, useRef, useState } from "react";
5
6import { hexToHsv, hsvToHex, type HsvColor } from "../../lib/color";
7import { ColorHueSlider, ColorSaturationValuePlane } from "../ui/color-picker";
8import { Input } from "../ui/input";
9import { Button } from "../ui/button";
10import { Popover, PopoverClose, PopoverPopup, PopoverTrigger } from "../ui/popover";
11import { normalizeProviderAccentColor } from "../../providerInstances";
12
13const FALLBACK_ACCENT_COLOR = "#2563eb";
14
15function ProviderCustomColorPanel(props: {
16 readonly value: string;
17 readonly onCommit: (value: string) => void;
18}) {
19 const { onCommit } = props;
20 const [hsv, setHsv] = useState(() => hexToHsv(props.value));
21 const currentColor = hsvToHex(hsv.h, hsv.s, hsv.v);
22 const [hexDraft, setHexDraft] = useState<string | null>(null);
23
24 const commitHsv = useCallback(
25 (nextHsv: HsvColor) => {
26 setHsv(nextHsv);
27 onCommit(hsvToHex(nextHsv.h, nextHsv.s, nextHsv.v));
28 },
29 [onCommit],
30 );
31
32 return (
33 <div className="w-56 bg-popover">
34 <ColorSaturationValuePlane
35 label="Accent color"
36 value={hsv}
37 onChange={commitHsv}
38 variant="edge"
39 />
40 <div className="grid gap-3 p-3">
41 <ColorHueSlider
42 label="Accent color hue"
43 value={hsv.h}
44 onChange={(h) => commitHsv({ ...hsv, h })}
45 />
46 <Input
47 nativeInput
48 size="compact"
49 value={hexDraft ?? currentColor}
50 onChange={(event) => {
51 const nextColor = event.currentTarget.value;
52 setHexDraft(nextColor);
53 if (!/^#[\da-f]{6}$/i.test(nextColor)) return;
54 setHsv(hexToHsv(nextColor));
55 props.onCommit(nextColor);
56 }}
57 onBlur={() => setHexDraft(null)}
58 font="mono"
59 aria-label="Custom hex accent color"
60 spellCheck={false}
61 />
62 </div>
63 </div>
64 );
65}
66
67function ProviderCustomColorPicker(props: {
68 readonly displayName: string;
69 readonly value: string | undefined;
70 readonly onCommit: (value: string) => void;
71 readonly onClear: () => void;
72}) {
73 const normalized = normalizeProviderAccentColor(props.value);
74
75 return (
76 <Popover>
77 <PopoverTrigger
78 render={
79 <Button
80 type="button"
81 size="icon-sm"
82 variant="ghost-muted"
83 style={normalized ? { backgroundColor: normalized } : undefined}
84 aria-label={`${normalized ? "Change" : "Add"} accent color for ${props.displayName}`}
85 >
86 {normalized ? (
87 <span className="sr-only">Change accent color</span>
88 ) : (
89 <PlusIcon aria-hidden />
90 )}
91 </Button>
92 }
93 />
94 <PopoverPopup side="bottom" align="start" sideOffset={6} padding="none">
95 <ProviderCustomColorPanel
96 value={normalized ?? FALLBACK_ACCENT_COLOR}
97 onCommit={props.onCommit}
98 />
99 {normalized ? (
100 <div className="border-t border-border/60 p-1">
101 <PopoverClose
102 render={
103 <Button
104 type="button"
105 size="compact"
106 variant="ghost-muted"
107 className="w-full justify-start"
108 onClick={props.onClear}
109 >
110 <XIcon className="size-3.5" aria-hidden />
111 Clear color
112 </Button>
113 }
114 />
115 </div>
116 ) : null}
117 </PopoverPopup>
118 </Popover>
119 );
120}
121
122export function ProviderAccentColorPicker(props: {
123 readonly displayName: string;
124 readonly value: string | undefined;
125 readonly onCommit: (value: string) => void;
126 readonly description?: string;
127 readonly commitDelayMs?: number;
128 /** `inline` renders only the swatch row, for callers that supply their own label. */
129 readonly layout?: "stacked" | "inline";
130}) {
131 const {
132 commitDelayMs = 0,
133 description,
134 displayName,
135 layout = "stacked",
136 onCommit,
137 value,
138 } = props;
139 const [optimisticValue, setOptimisticValue] = useState(() => value ?? "");
140 const commitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
141 const pendingCommitRef = useRef<string | null>(null);
142 const onCommitRef = useRef(onCommit);
143
144 useEffect(() => {
145 onCommitRef.current = onCommit;
146 }, [onCommit]);
147
148 useEffect(() => {
149 if (pendingCommitRef.current !== null) return;
150 setOptimisticValue(value ?? "");
151 }, [value]);
152
153 useEffect(() => {
154 return () => {
155 if (commitTimeoutRef.current !== null) {
156 clearTimeout(commitTimeoutRef.current);
157 }
158 const pendingCommit = pendingCommitRef.current;
159 if (pendingCommit !== null) {
160 onCommitRef.current(pendingCommit);
161 }
162 };
163 }, []);
164
165 const commitAccentColor = useCallback(
166 (value: string) => {
167 const normalizedValue = normalizeProviderAccentColor(value) ?? "";
168 setOptimisticValue(normalizedValue);
169
170 if (commitDelayMs <= 0) {
171 pendingCommitRef.current = null;
172 if (commitTimeoutRef.current !== null) {
173 clearTimeout(commitTimeoutRef.current);
174 commitTimeoutRef.current = null;
175 }
176 onCommit(normalizedValue);
177 return;
178 }
179
180 pendingCommitRef.current = normalizedValue;
181 if (commitTimeoutRef.current !== null) {
182 clearTimeout(commitTimeoutRef.current);
183 }
184 commitTimeoutRef.current = setTimeout(() => {
185 commitTimeoutRef.current = null;
186 const pendingCommit = pendingCommitRef.current;
187 pendingCommitRef.current = null;
188 if (pendingCommit !== null) {
189 onCommitRef.current(pendingCommit);
190 }
191 }, commitDelayMs);
192 },
193 [commitDelayMs, onCommit],
194 );
195
196 const normalized = normalizeProviderAccentColor(optimisticValue);
197 const picker = (
198 <ProviderCustomColorPicker
199 displayName={displayName}
200 value={normalized}
201 onCommit={commitAccentColor}
202 onClear={() => commitAccentColor("")}
203 />
204 );
205
206 if (layout === "inline") {
207 return picker;
208 }
209
210 return (
211 <div className="grid gap-2">
212 <span className="text-xs font-medium text-foreground">Accent color</span>
213 {picker}
214 {description ? <span className="text-xs text-muted-foreground">{description}</span> : null}
215 </div>
216 );
217}