| 1 | "use client"; |
| 2 | |
| 3 | import { PlusIcon, XIcon } from "lucide-react"; |
| 4 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 5 | |
| 6 | import { hexToHsv, hsvToHex, type HsvColor } from "../../lib/color"; |
| 7 | import { ColorHueSlider, ColorSaturationValuePlane } from "../ui/color-picker"; |
| 8 | import { Input } from "../ui/input"; |
| 9 | import { Button } from "../ui/button"; |
| 10 | import { Popover, PopoverClose, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 11 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| 12 | |
| 13 | const FALLBACK_ACCENT_COLOR = "#2563eb"; |
| 14 | |
| 15 | function 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 | |
| 67 | function 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 | |
| 122 | export 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 | } |