Files
kit-ui/components/color/ColorManipulation.tsx
Sebastian Krüger 6ecdc33933 feat: add cardBtn style for card title row buttons
Smaller variant for buttons that sit next to section labels in card headers
(Preview, Color, Results rows). Applied to QRPreview, FontPreview,
ColorManipulation, and FileConverter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 10:36:19 +01:00

358 lines
15 KiB
TypeScript

'use client';
import { useState, useEffect, Suspense } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { ColorPicker } from '@/components/color/ColorPicker';
import { ColorInfo } from '@/components/color/ColorInfo';
import { ManipulationPanel } from '@/components/color/ManipulationPanel';
import { PaletteGrid } from '@/components/color/PaletteGrid';
import { ExportMenu } from '@/components/color/ExportMenu';
import { useColorInfo, useGeneratePalette, useGenerateGradient } from '@/lib/color/api/queries';
import { Loader2, Share2, Plus, X, Palette, Layers } from 'lucide-react';
import { toast } from 'sonner';
import { cn, actionBtn, cardBtn } from '@/lib/utils';
import { MobileTabs } from '@/components/ui/mobile-tabs';
type HarmonyType = 'monochromatic' | 'analogous' | 'complementary' | 'triadic' | 'tetradic';
type RightTab = 'info' | 'adjust' | 'harmony' | 'gradient';
type MobileTab = 'pick' | 'explore';
const HARMONY_OPTS: { value: HarmonyType; label: string; desc: string }[] = [
{ value: 'monochromatic', label: 'Mono', desc: 'Single hue, varied lightness' },
{ value: 'analogous', label: 'Analogous', desc: 'Adjacent colors ±30°' },
{ value: 'complementary', label: 'Complement', desc: 'Opposite on wheel 180°' },
{ value: 'triadic', label: 'Triadic', desc: 'Three equal 120° steps' },
{ value: 'tetradic', label: 'Tetradic', desc: 'Four equal 90° steps' },
];
const RIGHT_TABS: { value: RightTab; label: string }[] = [
{ value: 'info', label: 'Info' },
{ value: 'adjust', label: 'Adjust' },
{ value: 'harmony', label: 'Harmony' },
{ value: 'gradient', label: 'Gradient' },
];
function ColorManipulationContent() {
const searchParams = useSearchParams();
const router = useRouter();
const [color, setColor] = useState(() => {
const urlColor = searchParams.get('color');
return urlColor ? `#${urlColor.replace('#', '')}` : '#ff0099';
});
const [rightTab, setRightTab] = useState<RightTab>('info');
const [mobileTab, setMobileTab] = useState<MobileTab>('pick');
// Harmony
const [harmonyType, setHarmonyType] = useState<HarmonyType>('complementary');
const [palette, setPalette] = useState<string[]>([]);
const paletteMutation = useGeneratePalette();
// Gradient
const [stops, setStops] = useState<string[]>(['#ff0099', '#0099ff']);
const [gradientCount, setGradientCount] = useState(10);
const [gradientResult, setGradientResult] = useState<string[]>([]);
const gradientMutation = useGenerateGradient();
const { data, isLoading } = useColorInfo({ colors: [color] });
const colorInfo = data?.colors[0];
useEffect(() => {
const hex = color.replace('#', '');
if (hex.length === 6 || hex.length === 3) {
router.push(`/color?color=${hex}`, { scroll: false });
}
}, [color, router]);
// Sync first gradient stop with active color
useEffect(() => {
setStops((prev) => [color, ...prev.slice(1)]);
}, [color]);
const handleShare = () => {
navigator.clipboard.writeText(`${window.location.origin}/color?color=${color.replace('#', '')}`);
toast.success('Link copied!');
};
const generateHarmony = async () => {
try {
const result = await paletteMutation.mutateAsync({ base: color, scheme: harmonyType });
setPalette([result.palette.primary, ...result.palette.secondary]);
toast.success(`Generated ${harmonyType} palette`);
} catch { toast.error('Failed to generate palette'); }
};
const generateGradient = async () => {
try {
const result = await gradientMutation.mutateAsync({ stops, count: gradientCount });
setGradientResult(result.gradient);
toast.success(`Generated ${result.gradient.length} colors`);
} catch { toast.error('Failed to generate gradient'); }
};
const updateStop = (i: number, v: string) => {
const next = [...stops];
next[i] = v;
setStops(next);
if (i === 0) setColor(v);
};
return (
<div className="flex flex-col gap-4">
<MobileTabs
tabs={[{ value: 'pick', label: 'Pick' }, { value: 'explore', label: 'Explore' }]}
active={mobileTab}
onChange={(v) => setMobileTab(v as MobileTab)}
/>
{/* ── Main layout ────────────────────────────────────────── */}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 120px)' }}
>
{/* Left panel: Picker + ColorInfo */}
<div
className={cn(
'lg:col-span-2 flex flex-col gap-3 overflow-hidden',
mobileTab !== 'pick' && 'hidden lg:flex'
)}
>
{/* Color picker card */}
<div className="glass rounded-xl p-4 shrink-0">
<div className="flex items-center justify-between mb-3">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Color
</span>
<button onClick={handleShare} className={cardBtn}>
<Share2 className="w-3 h-3" /> Share
</button>
</div>
<ColorPicker color={color} onChange={setColor} />
</div>
{/* Color info card */}
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-3 shrink-0">
Info
</span>
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent">
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-4 w-4 animate-spin text-muted-foreground/40" />
</div>
) : colorInfo ? (
<ColorInfo info={colorInfo} />
) : null}
</div>
</div>
</div>
{/* Right panel: tabbed tools */}
<div
className={cn(
'lg:col-span-3 flex flex-col overflow-hidden',
mobileTab !== 'explore' && 'hidden lg:flex'
)}
>
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
{/* Tab switcher */}
<div className="flex glass rounded-lg p-0.5 gap-0.5 mb-4 shrink-0">
{RIGHT_TABS.map(({ value, label }) => (
<button
key={value}
onClick={() => setRightTab(value)}
className={cn(
'flex-1 py-1.5 rounded-md text-xs font-medium transition-all',
rightTab === value
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
{label}
</button>
))}
</div>
{/* Tab content */}
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5">
{/* ── Info tab ─────────────────────────────── */}
{rightTab === 'info' && (
<div className="space-y-3">
{/* Large color preview */}
<div
className="w-full rounded-xl border border-white/8 transition-colors duration-300"
style={{ height: '140px', background: color }}
/>
{isLoading ? (
<div className="flex justify-center py-6">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground/40" />
</div>
) : colorInfo ? (
<ColorInfo info={colorInfo} />
) : null}
</div>
)}
{/* ── Adjust tab ───────────────────────────── */}
{rightTab === 'adjust' && (
<ManipulationPanel color={color} onColorChange={setColor} />
)}
{/* ── Harmony tab ──────────────────────────── */}
{rightTab === 'harmony' && (
<div className="space-y-4">
{/* Scheme selector */}
<div className="space-y-2">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Scheme
</span>
<div className="flex flex-wrap gap-1.5">
{HARMONY_OPTS.map((opt) => (
<button
key={opt.value}
onClick={() => setHarmonyType(opt.value)}
className={cn(
'px-2.5 py-1 rounded-lg border text-xs font-mono transition-all',
harmonyType === opt.value
? 'bg-primary/10 border-primary/40 text-primary'
: 'border-border/30 text-muted-foreground hover:border-primary/30 hover:text-foreground'
)}
>
{opt.label}
</button>
))}
</div>
<p className="text-[10px] text-muted-foreground/50 font-mono">
{HARMONY_OPTS.find((o) => o.value === harmonyType)?.desc}
</p>
</div>
<button
onClick={generateHarmony}
disabled={paletteMutation.isPending}
className={cn(actionBtn, 'w-full justify-center py-2')}
>
{paletteMutation.isPending
? <><Loader2 className="w-3 h-3 animate-spin" /> Generating</>
: <><Palette className="w-3 h-3" /> Generate Palette</>
}
</button>
{palette.length > 0 && (
<div className="space-y-4">
<PaletteGrid colors={palette} onColorClick={setColor} />
<div className="border-t border-border/25 pt-4">
<ExportMenu colors={palette} />
</div>
</div>
)}
</div>
)}
{/* ── Gradient tab ─────────────────────────── */}
{rightTab === 'gradient' && (
<div className="space-y-4">
{/* Color stops */}
<div className="space-y-2">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Stops
</span>
{stops.map((stop, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="color"
value={stop}
onChange={(e) => updateStop(i, e.target.value)}
className="w-8 h-8 rounded-lg cursor-pointer border border-border/40 bg-transparent shrink-0 p-0.5"
/>
<input
type="text"
value={stop}
onChange={(e) => updateStop(i, e.target.value)}
className="flex-1 bg-transparent border border-border/40 rounded-lg px-2.5 py-1.5 text-xs font-mono outline-none focus:border-primary/50 transition-colors"
/>
{i !== 0 && stops.length > 2 && (
<button
onClick={() => setStops(stops.filter((_, idx) => idx !== i))}
className="shrink-0 text-muted-foreground/35 hover:text-destructive transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
))}
<button
onClick={() => setStops([...stops, '#000000'])}
className="w-full py-1.5 rounded-lg border border-dashed border-border/30 text-xs text-muted-foreground/40 hover:text-foreground hover:border-primary/30 transition-all flex items-center justify-center gap-1"
>
<Plus className="w-3 h-3" /> Add stop
</button>
</div>
{/* Steps */}
<div className="flex items-center gap-3">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest shrink-0">
Steps
</span>
<input
type="number"
min={2}
max={100}
value={gradientCount}
onChange={(e) => setGradientCount(parseInt(e.target.value))}
className="w-20 bg-transparent border border-border/40 rounded-lg px-2.5 py-1.5 text-xs font-mono text-center outline-none focus:border-primary/50 transition-colors"
/>
</div>
<button
onClick={generateGradient}
disabled={gradientMutation.isPending}
className={cn(actionBtn, 'w-full justify-center py-2')}
>
{gradientMutation.isPending
? <><Loader2 className="w-3 h-3 animate-spin" /> Generating</>
: <><Layers className="w-3 h-3" /> Generate Gradient</>
}
</button>
{gradientResult.length > 0 && (
<div className="space-y-4">
{/* Gradient preview bar */}
<div
className="h-12 w-full rounded-xl border border-white/8"
style={{ background: `linear-gradient(to right, ${gradientResult.join(', ')})` }}
/>
<PaletteGrid colors={gradientResult} onColorClick={setColor} />
<div className="border-t border-border/25 pt-4">
<ExportMenu colors={gradientResult} />
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
export function ColorManipulation() {
return (
<Suspense fallback={
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground/40" />
</div>
}>
<ColorManipulationContent />
</Suspense>
);
}