brackt/app/components/ui/FlagBuilder.tsx
2026-05-06 14:47:37 -07:00

183 lines
6.5 KiB
TypeScript

import { useMemo, useState } from "react";
import { Check } from "lucide-react";
import { AVATAR_COLORS } from "~/lib/avatar-colors";
import { generateFlagConfig } from "~/lib/flag-generator";
import { FLAG_PATTERNS, type FlagConfig, type FlagPattern } from "~/lib/flag-types";
import { cn } from "~/lib/utils";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { FlagSvg } from "~/components/ui/FlagSvg";
interface FlagBuilderProps {
value?: FlagConfig | null;
seed: string;
onChange: (config: FlagConfig) => void;
}
const SWATCHES = [
...AVATAR_COLORS,
"#22c55e",
"#14b8a6",
"#06b6d4",
"#0ea5e9",
"#6366f1",
"#a855f7",
"#d946ef",
"#ec4899",
"#f43f5e",
"#dc2626",
"#f97316",
"#eab308",
"#84cc16",
"#ffffff",
"#d1d5db",
"#6b7280",
"#111827",
"#000000",
];
const COLOR_SLOT_KEYS = ["primary", "secondary", "accent"];
const FALLBACK_COLORS = ["#adf661", "#111827", "#ffffff"];
const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
function normalizeColors(colors: string[]): string[] {
return [
colors[0] ?? FALLBACK_COLORS[0],
colors[1] ?? FALLBACK_COLORS[1],
colors[2] ?? FALLBACK_COLORS[2],
];
}
export function FlagBuilder({ value: configValue, seed, onChange }: FlagBuilderProps) {
const initial = useMemo(() => configValue ?? generateFlagConfig(seed), [seed, configValue]);
const [customInputs, setCustomInputs] = useState(() => normalizeColors(initial.colors));
const [selectedColorIndex, setSelectedColorIndex] = useState(0);
const colors = normalizeColors(initial.colors);
const config = { ...initial, colors };
const colorSlots = colors.map((color, index) => ({
key: COLOR_SLOT_KEYS[index],
label: `Color ${index + 1}`,
color,
index,
}));
const activeColorIndex = Math.min(selectedColorIndex, colors.length - 1);
const activeColor = colors[activeColorIndex];
const customColor = customInputs[activeColorIndex] ?? activeColor;
const isValidCustomColor = HEX_COLOR_RE.test(customColor);
function updatePattern(pattern: FlagPattern) {
onChange({ ...config, pattern });
}
function updateColor(index: number, color: string) {
const nextColors = [...colors];
nextColors[index] = color;
onChange({ ...config, colors: nextColors });
}
return (
<div className="space-y-5">
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{FLAG_PATTERNS.map((pattern) => {
const selected = config.pattern === pattern;
return (
<button
key={pattern}
type="button"
title={pattern}
aria-label={pattern}
aria-pressed={selected}
onClick={() => updatePattern(pattern)}
className={cn(
"relative flex aspect-square items-center justify-center rounded-md border bg-background p-1 transition-colors",
selected ? "border-[#adf661] ring-2 ring-[#adf661]/30" : "border-border hover:border-muted-foreground"
)}
>
<FlagSvg config={{ ...config, pattern }} size={60} className="h-full w-full" />
{selected && (
<span className="absolute right-1 top-1 rounded-full bg-[#adf661] p-0.5 text-black">
<Check className="h-3 w-3" />
</span>
)}
</button>
);
})}
</div>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{colorSlots.map((slot) => (
<button
key={slot.key}
type="button"
aria-pressed={activeColorIndex === slot.index}
onClick={() => setSelectedColorIndex(slot.index)}
className={cn(
"flex h-10 items-center gap-2 rounded-md border px-2 text-sm font-medium transition-colors",
activeColorIndex === slot.index
? "border-[#adf661] bg-[#adf661]/10"
: "border-border hover:border-muted-foreground"
)}
>
<span
className="h-5 w-5 shrink-0 rounded-sm border border-border"
style={{ backgroundColor: slot.color }}
/>
{slot.label}
</button>
))}
</div>
<div className="space-y-2">
<Label>{colorSlots[activeColorIndex]?.label ?? "Color"}</Label>
<div className="flex flex-wrap items-center gap-2">
{SWATCHES.map((swatch) => (
<button
key={swatch}
type="button"
title={swatch}
aria-label={`Use ${swatch}`}
onClick={() => updateColor(activeColorIndex, swatch)}
className={cn(
"h-8 w-8 rounded-md border transition-transform hover:scale-105",
activeColor.toLowerCase() === swatch.toLowerCase()
? "border-[#adf661] ring-2 ring-[#adf661]/30"
: "border-border"
)}
style={{ backgroundColor: swatch }}
/>
))}
<button
type="button"
title={isValidCustomColor ? customColor : "Custom color"}
aria-label={isValidCustomColor ? `Use custom color ${customColor}` : "Custom color"}
disabled={!isValidCustomColor}
onClick={() => {
if (isValidCustomColor) updateColor(activeColorIndex, customColor);
}}
className={cn(
"h-8 w-8 rounded-md border transition-transform",
isValidCustomColor ? "hover:scale-105" : "cursor-not-allowed opacity-40",
isValidCustomColor && activeColor.toLowerCase() === customColor.toLowerCase()
? "border-[#adf661] ring-2 ring-[#adf661]/30"
: "border-border"
)}
style={{ backgroundColor: isValidCustomColor ? customColor : "transparent" }}
/>
<Input
value={customInputs[activeColorIndex] ?? activeColor}
onChange={(event) => {
const inputValue = event.target.value;
const next = [...customInputs];
next[activeColorIndex] = inputValue;
setCustomInputs(next);
if (HEX_COLOR_RE.test(inputValue)) updateColor(activeColorIndex, inputValue);
}}
maxLength={7}
className="h-8 w-28"
/>
</div>
</div>
</div>
</div>
);
}