183 lines
6.5 KiB
TypeScript
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>
|
|
);
|
|
}
|