| 1 | "use client"; |
| 2 | |
| 3 | import { |
| 4 | Check, |
| 5 | Layers, |
| 6 | MoveDown, |
| 7 | MoveUp, |
| 8 | Plus, |
| 9 | RotateCcw, |
| 10 | Trash2, |
| 11 | } from "lucide-react"; |
| 12 | import { useCallback, useState } from "react"; |
| 13 | |
| 14 | import { Badge } from "@/components/ui/badge"; |
| 15 | import { Button } from "@/components/ui/button"; |
| 16 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; |
| 17 | import ColorPicker from "@/components/ui/color-picker"; |
| 18 | import { Input } from "@/components/ui/input"; |
| 19 | import { Label } from "@/components/ui/label"; |
| 20 | import { |
| 21 | Select, |
| 22 | SelectContent, |
| 23 | SelectItem, |
| 24 | SelectTrigger, |
| 25 | SelectValue, |
| 26 | } from "@/components/ui/select"; |
| 27 | import { Separator } from "@/components/ui/separator"; |
| 28 | import { Slider } from "@/components/ui/slider"; |
| 29 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; |
| 30 | import { useToast } from "@/components/ui/use-toast"; |
| 31 | |
| 32 | interface ColorStop { |
| 33 | id: string; |
| 34 | color: string; |
| 35 | position: number; |
| 36 | } |
| 37 | |
| 38 | interface GradientLayer { |
| 39 | id: string; |
| 40 | type: "linear" | "radial"; |
| 41 | direction: number; // linear (deg) |
| 42 | shape: "circle" | "ellipse"; // radial |
| 43 | position: string; // radial preset position |
| 44 | size: |
| 45 | | "closest-side" |
| 46 | | "closest-corner" |
| 47 | | "farthest-side" |
| 48 | | "farthest-corner" |
| 49 | | "custom"; |
| 50 | customRadius: { x: number; y: number }; |
| 51 | positionX: number; |
| 52 | positionY: number; |
| 53 | useCustomPosition: boolean; |
| 54 | colorStops: ColorStop[]; |
| 55 | opacity: number; // 0-100 |
| 56 | blendMode: string; |
| 57 | } |
| 58 | |
| 59 | const defaultLayer: Omit<GradientLayer, "id"> = { |
| 60 | type: "linear", |
| 61 | direction: 90, |
| 62 | shape: "circle", |
| 63 | position: "center", |
| 64 | size: "farthest-corner", |
| 65 | customRadius: { x: 50, y: 50 }, |
| 66 | positionX: 50, |
| 67 | positionY: 50, |
| 68 | useCustomPosition: false, |
| 69 | opacity: 100, |
| 70 | blendMode: "normal", |
| 71 | colorStops: [ |
| 72 | { id: "1", color: "#ff0000", position: 0 }, |
| 73 | { id: "2", color: "#0000ff", position: 100 }, |
| 74 | ], |
| 75 | }; |
| 76 | |
| 77 | const radialPositions = [ |
| 78 | "center", |
| 79 | "top", |
| 80 | "bottom", |
| 81 | "left", |
| 82 | "right", |
| 83 | "top left", |
| 84 | "top right", |
| 85 | "bottom left", |
| 86 | "bottom right", |
| 87 | ]; |
| 88 | |
| 89 | const radialSizes = [ |
| 90 | { value: "closest-side", label: "Closest Side" }, |
| 91 | { value: "closest-corner", label: "Closest Corner" }, |
| 92 | { value: "farthest-side", label: "Farthest Side" }, |
| 93 | { value: "farthest-corner", label: "Farthest Corner" }, |
| 94 | { value: "custom", label: "Custom Size" }, |
| 95 | ]; |
| 96 | |
| 97 | const blendModes = [ |
| 98 | "normal", |
| 99 | "multiply", |
| 100 | "screen", |
| 101 | "overlay", |
| 102 | "darken", |
| 103 | "lighten", |
| 104 | "color-dodge", |
| 105 | "color-burn", |
| 106 | "hard-light", |
| 107 | "soft-light", |
| 108 | "difference", |
| 109 | "exclusion", |
| 110 | "hue", |
| 111 | "saturation", |
| 112 | "color", |
| 113 | "luminosity", |
| 114 | ]; |
| 115 | |
| 116 | export function GradientMaker({ |
| 117 | onApply, |
| 118 | onClose, |
| 119 | }: { |
| 120 | onApply?: (cssBackground: string, blendModes?: string) => void; |
| 121 | onClose?: () => void; |
| 122 | }) { |
| 123 | const [layers, setLayers] = useState<GradientLayer[]>([ |
| 124 | { ...defaultLayer, id: "layer-1" }, |
| 125 | ]); |
| 126 | const [activeLayerId, setActiveLayerId] = useState<string>("layer-1"); |
| 127 | const { toast } = useToast(); |
| 128 | |
| 129 | const activeLayer = |
| 130 | layers.find((layer) => layer.id === activeLayerId) || layers[0]!; |
| 131 | |
| 132 | const generateLayerCSS = useCallback((layer: GradientLayer) => { |
| 133 | const { |
| 134 | type, |
| 135 | direction, |
| 136 | shape, |
| 137 | position, |
| 138 | size, |
| 139 | customRadius, |
| 140 | positionX, |
| 141 | positionY, |
| 142 | useCustomPosition, |
| 143 | colorStops, |
| 144 | } = layer; |
| 145 | const sortedStops = [...colorStops].sort((a, b) => a.position - b.position); |
| 146 | const colorString = sortedStops |
| 147 | .map((stop) => `${stop.color} ${stop.position}%`) |
| 148 | .join(", "); |
| 149 | |
| 150 | if (type === "linear") { |
| 151 | return `linear-gradient(${direction}deg, ${colorString})`; |
| 152 | } |
| 153 | let sizeString = ""; |
| 154 | if (size === "custom") { |
| 155 | sizeString = |
| 156 | shape === "circle" |
| 157 | ? `${customRadius.x}px` |
| 158 | : `${customRadius.x}px ${customRadius.y}px`; |
| 159 | } else { |
| 160 | sizeString = size; |
| 161 | } |
| 162 | const pos = useCustomPosition |
| 163 | ? `at ${positionX}% ${positionY}%` |
| 164 | : `at ${position}`; |
| 165 | return `radial-gradient(${shape} ${sizeString} ${pos}, ${colorString})`; |
| 166 | }, []); |
| 167 | |
| 168 | const generateBackgroundImage = useCallback(() => { |
| 169 | return layers.map((layer) => generateLayerCSS(layer)).join(", "); |
| 170 | }, [layers, generateLayerCSS]); |
| 171 | |
| 172 | const backgroundBlendModes = layers.map((l) => l.blendMode).join(", "); |
| 173 | |
| 174 | const applyToBackground = () => { |
| 175 | if (onApply) onApply(generateBackgroundImage(), backgroundBlendModes); |
| 176 | toast({ |
| 177 | title: "Applied", |
| 178 | description: "Background applied to presentation.", |
| 179 | }); |
| 180 | onClose?.(); |
| 181 | }; |
| 182 | |
| 183 | const addLayer = () => { |
| 184 | const newId = `layer-${Date.now()}`; |
| 185 | const newLayer: GradientLayer = { |
| 186 | ...defaultLayer, |
| 187 | id: newId, |
| 188 | colorStops: [ |
| 189 | { id: `${newId}-1`, color: "#ffffff", position: 0 }, |
| 190 | { id: `${newId}-2`, color: "#000000", position: 100 }, |
| 191 | ], |
| 192 | }; |
| 193 | setLayers((prev) => [...prev, newLayer]); |
| 194 | setActiveLayerId(newId); |
| 195 | }; |
| 196 | |
| 197 | const removeLayer = (layerId: string) => { |
| 198 | if (layers.length <= 1) { |
| 199 | toast({ |
| 200 | title: "Cannot remove layer", |
| 201 | description: "At least one layer is required.", |
| 202 | variant: "destructive", |
| 203 | }); |
| 204 | return; |
| 205 | } |
| 206 | setLayers((prev) => prev.filter((l) => l.id !== layerId)); |
| 207 | if (activeLayerId === layerId) |
| 208 | setActiveLayerId( |
| 209 | layers.find((l) => l.id !== layerId)?.id || layers[0]!.id, |
| 210 | ); |
| 211 | }; |
| 212 | |
| 213 | const moveLayer = (layerId: string, dir: "up" | "down") => { |
| 214 | setLayers((prev) => { |
| 215 | const idx = prev.findIndex((l) => l.id === layerId); |
| 216 | if (idx === -1) return prev; |
| 217 | const to = dir === "up" ? idx - 1 : idx + 1; |
| 218 | if (to < 0 || to >= prev.length) return prev; |
| 219 | const copy = [...prev]; |
| 220 | const [moved] = copy.splice(idx, 1); |
| 221 | copy.splice(to, 0, moved!); |
| 222 | return copy; |
| 223 | }); |
| 224 | }; |
| 225 | |
| 226 | const updateActiveLayer = (updates: Partial<GradientLayer>) => { |
| 227 | setLayers((prev) => |
| 228 | prev.map((l) => (l.id === activeLayerId ? { ...l, ...updates } : l)), |
| 229 | ); |
| 230 | }; |
| 231 | |
| 232 | const addColorStop = () => { |
| 233 | const newId = Date.now().toString(); |
| 234 | const newPos = |
| 235 | activeLayer.colorStops.length > 0 |
| 236 | ? Math.min( |
| 237 | Math.max(...activeLayer.colorStops.map((s) => s.position)) + 10, |
| 238 | 100, |
| 239 | ) |
| 240 | : 50; |
| 241 | updateActiveLayer({ |
| 242 | colorStops: [ |
| 243 | ...activeLayer.colorStops, |
| 244 | { id: newId, color: "#ffffff", position: newPos }, |
| 245 | ], |
| 246 | }); |
| 247 | }; |
| 248 | const removeColorStop = (id: string) => { |
| 249 | if (activeLayer.colorStops.length <= 2) { |
| 250 | toast({ |
| 251 | title: "Cannot remove color stop", |
| 252 | description: "A gradient requires at least two stops.", |
| 253 | variant: "destructive", |
| 254 | }); |
| 255 | return; |
| 256 | } |
| 257 | updateActiveLayer({ |
| 258 | colorStops: activeLayer.colorStops.filter((s) => s.id !== id), |
| 259 | }); |
| 260 | }; |
| 261 | const updateColorStop = (id: string, updates: Partial<ColorStop>) => { |
| 262 | updateActiveLayer({ |
| 263 | colorStops: activeLayer.colorStops.map((s) => |
| 264 | s.id === id ? { ...s, ...updates } : s, |
| 265 | ), |
| 266 | }); |
| 267 | }; |
| 268 | |
| 269 | return ( |
| 270 | <div className="grid grid-cols-1 gap-6 px-4 lg:grid-cols-2"> |
| 271 | <Card> |
| 272 | <CardHeader> |
| 273 | <CardTitle className="flex items-center gap-2"> |
| 274 | <Layers className="h-5 w-5" /> Background Preview |
| 275 | </CardTitle> |
| 276 | </CardHeader> |
| 277 | <CardContent className="space-y-4"> |
| 278 | <div |
| 279 | className="h-64 w-full rounded-lg border" |
| 280 | style={{ background: generateBackgroundImage() }} |
| 281 | /> |
| 282 | <div className="flex gap-2"> |
| 283 | <Button |
| 284 | onClick={applyToBackground} |
| 285 | size="sm" |
| 286 | variant="default" |
| 287 | className="w-full" |
| 288 | > |
| 289 | <Check className="mr-2 h-4 w-4" /> Save Background |
| 290 | </Button> |
| 291 | </div> |
| 292 | </CardContent> |
| 293 | </Card> |
| 294 | |
| 295 | <Card> |
| 296 | <CardHeader className="flex flex-row items-center justify-between"> |
| 297 | <CardTitle>Layer Controls</CardTitle> |
| 298 | <Button |
| 299 | onClick={() => { |
| 300 | setLayers([{ ...defaultLayer, id: "layer-1" }]); |
| 301 | setActiveLayerId("layer-1"); |
| 302 | }} |
| 303 | size="sm" |
| 304 | variant="outline" |
| 305 | > |
| 306 | <RotateCcw className="mr-2 h-4 w-4" /> Reset All |
| 307 | </Button> |
| 308 | </CardHeader> |
| 309 | <CardContent className="space-y-6"> |
| 310 | <div className="space-y-4"> |
| 311 | <div className="flex items-center justify-between"> |
| 312 | <Label>Gradient Layers ({layers.length})</Label> |
| 313 | <Button onClick={addLayer} size="sm" variant="outline"> |
| 314 | <Plus className="mr-2 h-4 w-4" /> Add Layer |
| 315 | </Button> |
| 316 | </div> |
| 317 | <div className="max-h-40 space-y-2 overflow-y-auto"> |
| 318 | {layers.map((layer, index) => ( |
| 319 | <div |
| 320 | key={layer.id} |
| 321 | className={`flex cursor-pointer items-center gap-2 rounded-lg border p-2 transition-colors ${activeLayerId === layer.id ? "border-primary bg-primary/10" : "hover:bg-muted/50"}`} |
| 322 | onClick={() => setActiveLayerId(layer.id)} |
| 323 | > |
| 324 | <div |
| 325 | className="h-6 w-6 rounded border" |
| 326 | style={{ background: generateLayerCSS(layer) }} |
| 327 | /> |
| 328 | <div className="flex-1"> |
| 329 | <div className="text-sm font-medium"> |
| 330 | Layer {index + 1} ({layer.type}) |
| 331 | </div> |
| 332 | <div className="text-xs text-muted-foreground"> |
| 333 | {layer.opacity}% opacity • {layer.blendMode} |
| 334 | </div> |
| 335 | </div> |
| 336 | <div className="flex gap-1"> |
| 337 | <Button |
| 338 | onClick={(e) => { |
| 339 | e.stopPropagation(); |
| 340 | moveLayer(layer.id, "up"); |
| 341 | }} |
| 342 | size="icon" |
| 343 | variant="ghost" |
| 344 | className="h-6 w-6" |
| 345 | disabled={index === 0} |
| 346 | > |
| 347 | <MoveUp className="h-3 w-3" /> |
| 348 | </Button> |
| 349 | <Button |
| 350 | onClick={(e) => { |
| 351 | e.stopPropagation(); |
| 352 | moveLayer(layer.id, "down"); |
| 353 | }} |
| 354 | size="icon" |
| 355 | variant="ghost" |
| 356 | className="h-6 w-6" |
| 357 | disabled={index === layers.length - 1} |
| 358 | > |
| 359 | <MoveDown className="h-3 w-3" /> |
| 360 | </Button> |
| 361 | <Button |
| 362 | onClick={(e) => { |
| 363 | e.stopPropagation(); |
| 364 | removeLayer(layer.id); |
| 365 | }} |
| 366 | size="icon" |
| 367 | variant="ghost" |
| 368 | className="h-6 w-6 text-destructive hover:text-destructive" |
| 369 | > |
| 370 | <Trash2 className="h-3 w-3" /> |
| 371 | </Button> |
| 372 | </div> |
| 373 | </div> |
| 374 | ))} |
| 375 | </div> |
| 376 | </div> |
| 377 | |
| 378 | <Separator /> |
| 379 | |
| 380 | <div className="space-y-4"> |
| 381 | <Label className="text-base font-semibold"> |
| 382 | Editing: Layer{" "} |
| 383 | {layers.findIndex((l) => l.id === activeLayerId) + 1} |
| 384 | </Label> |
| 385 | <div className="grid grid-cols-2 gap-4"> |
| 386 | <div className="space-y-2"> |
| 387 | <Label>Opacity ({activeLayer.opacity}%)</Label> |
| 388 | <Slider |
| 389 | value={[activeLayer.opacity]} |
| 390 | onValueChange={([v]) => updateActiveLayer({ opacity: v })} |
| 391 | max={100} |
| 392 | min={0} |
| 393 | step={1} |
| 394 | className="w-full" |
| 395 | /> |
| 396 | </div> |
| 397 | <div className="space-y-2"> |
| 398 | <Label>Blend Mode</Label> |
| 399 | <Select |
| 400 | value={activeLayer.blendMode} |
| 401 | onValueChange={(v) => updateActiveLayer({ blendMode: v })} |
| 402 | > |
| 403 | <SelectTrigger> |
| 404 | <SelectValue /> |
| 405 | </SelectTrigger> |
| 406 | <SelectContent> |
| 407 | {blendModes.map((mode) => ( |
| 408 | <SelectItem key={mode} value={mode}> |
| 409 | {mode} |
| 410 | </SelectItem> |
| 411 | ))} |
| 412 | </SelectContent> |
| 413 | </Select> |
| 414 | </div> |
| 415 | </div> |
| 416 | |
| 417 | <Tabs |
| 418 | value={activeLayer.type} |
| 419 | onValueChange={(v) => |
| 420 | updateActiveLayer({ type: v as "linear" | "radial" }) |
| 421 | } |
| 422 | > |
| 423 | <TabsList className="grid w-full grid-cols-2"> |
| 424 | <TabsTrigger value="linear">Linear</TabsTrigger> |
| 425 | <TabsTrigger value="radial">Radial</TabsTrigger> |
| 426 | </TabsList> |
| 427 | |
| 428 | <TabsContent value="linear" className="space-y-4"> |
| 429 | <div className="space-y-2"> |
| 430 | <Label>Direction ({activeLayer.direction}°)</Label> |
| 431 | <Slider |
| 432 | value={[activeLayer.direction]} |
| 433 | onValueChange={([v]) => updateActiveLayer({ direction: v })} |
| 434 | max={360} |
| 435 | min={0} |
| 436 | step={1} |
| 437 | className="w-full" |
| 438 | /> |
| 439 | </div> |
| 440 | </TabsContent> |
| 441 | |
| 442 | <TabsContent value="radial" className="space-y-4"> |
| 443 | <div className="grid grid-cols-2 gap-4"> |
| 444 | <div className="space-y-2"> |
| 445 | <Label>Shape</Label> |
| 446 | <Select |
| 447 | value={activeLayer.shape} |
| 448 | onValueChange={(v) => |
| 449 | updateActiveLayer({ shape: v as "circle" | "ellipse" }) |
| 450 | } |
| 451 | > |
| 452 | <SelectTrigger> |
| 453 | <SelectValue /> |
| 454 | </SelectTrigger> |
| 455 | <SelectContent> |
| 456 | <SelectItem value="circle">Circle</SelectItem> |
| 457 | <SelectItem value="ellipse">Ellipse</SelectItem> |
| 458 | </SelectContent> |
| 459 | </Select> |
| 460 | </div> |
| 461 | <div className="space-y-2"> |
| 462 | <Label>Size</Label> |
| 463 | <Select |
| 464 | value={activeLayer.size} |
| 465 | onValueChange={(v) => |
| 466 | updateActiveLayer({ size: v as GradientLayer["size"] }) |
| 467 | } |
| 468 | > |
| 469 | <SelectTrigger> |
| 470 | <SelectValue /> |
| 471 | </SelectTrigger> |
| 472 | <SelectContent> |
| 473 | {radialSizes.map((s) => ( |
| 474 | <SelectItem key={s.value} value={s.value}> |
| 475 | {s.label} |
| 476 | </SelectItem> |
| 477 | ))} |
| 478 | </SelectContent> |
| 479 | </Select> |
| 480 | </div> |
| 481 | </div> |
| 482 | |
| 483 | {activeLayer.size === "custom" && ( |
| 484 | <div className="rounded-lg border bg-muted/50 p-4"> |
| 485 | <Label className="text-sm font-medium">Custom Radius</Label> |
| 486 | <div className="grid grid-cols-1 gap-4"> |
| 487 | <div className="space-y-2"> |
| 488 | <Label className="text-xs"> |
| 489 | {activeLayer.shape === "circle" |
| 490 | ? "Radius" |
| 491 | : "X Radius"}{" "} |
| 492 | ({activeLayer.customRadius.x}px) |
| 493 | </Label> |
| 494 | <Slider |
| 495 | value={[activeLayer.customRadius.x]} |
| 496 | onValueChange={([v]) => |
| 497 | updateActiveLayer({ |
| 498 | customRadius: { |
| 499 | ...activeLayer.customRadius, |
| 500 | x: v!, |
| 501 | }, |
| 502 | }) |
| 503 | } |
| 504 | max={500} |
| 505 | min={10} |
| 506 | step={5} |
| 507 | className="w-full" |
| 508 | /> |
| 509 | </div> |
| 510 | {activeLayer.shape === "ellipse" && ( |
| 511 | <div className="space-y-2"> |
| 512 | <Label className="text-xs"> |
| 513 | Y Radius ({activeLayer.customRadius.y}px) |
| 514 | </Label> |
| 515 | <Slider |
| 516 | value={[activeLayer.customRadius.y]} |
| 517 | onValueChange={([v]) => |
| 518 | updateActiveLayer({ |
| 519 | customRadius: { |
| 520 | ...activeLayer.customRadius, |
| 521 | y: v!, |
| 522 | }, |
| 523 | }) |
| 524 | } |
| 525 | max={500} |
| 526 | min={10} |
| 527 | step={5} |
| 528 | className="w-full" |
| 529 | /> |
| 530 | </div> |
| 531 | )} |
| 532 | </div> |
| 533 | </div> |
| 534 | )} |
| 535 | |
| 536 | <div className="space-y-4"> |
| 537 | <div className="flex items-center justify-between"> |
| 538 | <Label>Position</Label> |
| 539 | <Button |
| 540 | variant="outline" |
| 541 | size="sm" |
| 542 | onClick={() => |
| 543 | updateActiveLayer({ |
| 544 | useCustomPosition: !activeLayer.useCustomPosition, |
| 545 | }) |
| 546 | } |
| 547 | > |
| 548 | {activeLayer.useCustomPosition |
| 549 | ? "Use Presets" |
| 550 | : "Custom Position"} |
| 551 | </Button> |
| 552 | </div> |
| 553 | {activeLayer.useCustomPosition ? ( |
| 554 | <div className="rounded-lg border bg-muted/50 p-4"> |
| 555 | <div className="space-y-2"> |
| 556 | <Label className="text-xs"> |
| 557 | X Position ({activeLayer.positionX}%) |
| 558 | </Label> |
| 559 | <Slider |
| 560 | value={[activeLayer.positionX]} |
| 561 | onValueChange={([v]) => |
| 562 | updateActiveLayer({ positionX: v }) |
| 563 | } |
| 564 | max={100} |
| 565 | min={0} |
| 566 | step={1} |
| 567 | className="w-full" |
| 568 | /> |
| 569 | </div> |
| 570 | <div className="space-y-2"> |
| 571 | <Label className="text-xs"> |
| 572 | Y Position ({activeLayer.positionY}%) |
| 573 | </Label> |
| 574 | <Slider |
| 575 | value={[activeLayer.positionY]} |
| 576 | onValueChange={([v]) => |
| 577 | updateActiveLayer({ positionY: v }) |
| 578 | } |
| 579 | max={100} |
| 580 | min={0} |
| 581 | step={1} |
| 582 | className="w-full" |
| 583 | /> |
| 584 | </div> |
| 585 | </div> |
| 586 | ) : ( |
| 587 | <Select |
| 588 | value={activeLayer.position} |
| 589 | onValueChange={(v) => updateActiveLayer({ position: v })} |
| 590 | > |
| 591 | <SelectTrigger> |
| 592 | <SelectValue /> |
| 593 | </SelectTrigger> |
| 594 | <SelectContent> |
| 595 | {radialPositions.map((p) => ( |
| 596 | <SelectItem key={p} value={p}> |
| 597 | {p} |
| 598 | </SelectItem> |
| 599 | ))} |
| 600 | </SelectContent> |
| 601 | </Select> |
| 602 | )} |
| 603 | </div> |
| 604 | </TabsContent> |
| 605 | </Tabs> |
| 606 | |
| 607 | <Separator /> |
| 608 | |
| 609 | <div className="space-y-4"> |
| 610 | <div className="flex items-center justify-between"> |
| 611 | <Label>Color Stops</Label> |
| 612 | <Button onClick={addColorStop} size="sm" variant="outline"> |
| 613 | <Plus className="mr-2 h-4 w-4" /> Add Color |
| 614 | </Button> |
| 615 | </div> |
| 616 | <div className="space-y-3"> |
| 617 | {activeLayer.colorStops |
| 618 | .sort((a, b) => a.position - b.position) |
| 619 | .map((stop) => ( |
| 620 | <div |
| 621 | key={stop.id} |
| 622 | className="flex items-center gap-3 rounded-lg border p-3" |
| 623 | > |
| 624 | <div className="flex flex-1 items-center gap-2"> |
| 625 | <ColorPicker |
| 626 | value={stop.color} |
| 627 | onChange={(c) => |
| 628 | updateColorStop(stop.id, { color: c }) |
| 629 | } |
| 630 | > |
| 631 | <Button |
| 632 | variant="outline" |
| 633 | className="h-8 w-8 p-0" |
| 634 | style={{ backgroundColor: stop.color }} |
| 635 | /> |
| 636 | </ColorPicker> |
| 637 | <Input |
| 638 | value={stop.color} |
| 639 | onChange={(e) => |
| 640 | updateColorStop(stop.id, { color: e.target.value }) |
| 641 | } |
| 642 | className="flex-1 font-mono text-sm" |
| 643 | placeholder="#ffffff" |
| 644 | /> |
| 645 | </div> |
| 646 | <div className="flex items-center gap-2"> |
| 647 | <Badge variant="secondary" className="text-xs"> |
| 648 | {stop.position}% |
| 649 | </Badge> |
| 650 | <div className="w-20"> |
| 651 | <Slider |
| 652 | value={[stop.position]} |
| 653 | onValueChange={([v]) => |
| 654 | updateColorStop(stop.id, { position: v }) |
| 655 | } |
| 656 | max={100} |
| 657 | min={0} |
| 658 | step={1} |
| 659 | /> |
| 660 | </div> |
| 661 | <Button |
| 662 | onClick={() => removeColorStop(stop.id)} |
| 663 | size="icon" |
| 664 | variant="ghost" |
| 665 | className="h-8 w-8 text-destructive hover:text-destructive" |
| 666 | > |
| 667 | <Trash2 className="h-4 w-4" /> |
| 668 | </Button> |
| 669 | </div> |
| 670 | </div> |
| 671 | ))} |
| 672 | </div> |
| 673 | </div> |
| 674 | </div> |
| 675 | </CardContent> |
| 676 | </Card> |
| 677 | </div> |
| 678 | ); |
| 679 | } |
| 680 | |
| 681 | export type { GradientLayer }; |
| 682 |