Files
kit-ui/components/favicon/FaviconGenerator.tsx
Sebastian Krüger 002edc1532 refactor: extract CodeSnippet to shared ui component
Move components/favicon/CodeSnippet.tsx → components/ui/code-snippet.tsx.
Update Favicon tool import path. Replace Animate tool's local CodeBlock
(with external copy/download buttons) with the shared CodeSnippet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:42:12 +01:00

289 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import * as React from 'react';
import { Download, Loader2, Code2, Globe, Layout, FileImage } from 'lucide-react';
import { FaviconFileUpload } from './FaviconFileUpload';
import { ColorInput } from '@/components/ui/color-input';
import { CodeSnippet } from '@/components/ui/code-snippet';
import { generateFaviconSet } from '@/lib/favicon/faviconService';
import { downloadBlobsAsZip } from '@/lib/media/utils/fileUtils';
import type { FaviconSet, FaviconOptions } from '@/types/favicon';
import { toast } from 'sonner';
import { cn } from '@/lib/utils/cn';
type Tab = 'icons' | 'html' | 'manifest';
type MobileTab = 'setup' | 'results';
const TABS: { value: Tab; label: string; icon: React.ReactNode }[] = [
{ value: 'icons', label: 'Icons', icon: <Layout className="w-3 h-3" /> },
{ value: 'html', label: 'HTML', icon: <Code2 className="w-3 h-3" /> },
{ value: 'manifest', label: 'Manifest', icon: <Globe className="w-3 h-3" /> },
];
const actionBtn =
'flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs glass rounded-md border border-border/30 text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/10 transition-all disabled:opacity-40 disabled:cursor-not-allowed';
const inputCls =
'w-full bg-transparent border border-border/40 rounded-lg px-3 py-2 text-xs font-mono outline-none focus:border-primary/50 transition-colors text-foreground/80 placeholder:text-muted-foreground/30';
export function FaviconGenerator() {
const [sourceFile, setSourceFile] = React.useState<File | null>(null);
const [options, setOptions] = React.useState<FaviconOptions>({
name: 'My App',
shortName: 'App',
backgroundColor: '#ffffff',
themeColor: '#3b82f6',
});
const [isGenerating, setIsGenerating] = React.useState(false);
const [progress, setProgress] = React.useState(0);
const [result, setResult] = React.useState<FaviconSet | null>(null);
const [tab, setTab] = React.useState<Tab>('icons');
const [mobileTab, setMobileTab] = React.useState<MobileTab>('setup');
const handleGenerate = async () => {
if (!sourceFile) { toast.error('Please upload a source image'); return; }
setIsGenerating(true);
setProgress(0);
try {
const resultSet = await generateFaviconSet(sourceFile, options, (p) => setProgress(p));
setResult(resultSet);
setMobileTab('results');
toast.success('Favicon set generated!');
} catch (error) {
console.error(error);
toast.error('Failed to generate favicons');
} finally {
setIsGenerating(false);
}
};
const handleDownloadAll = async () => {
if (!result) return;
const files = result.icons.map((icon) => ({ blob: icon.blob!, filename: icon.name }));
const manifestBlob = new Blob([result.manifest], { type: 'application/json' });
files.push({ blob: manifestBlob, filename: 'site.webmanifest' });
await downloadBlobsAsZip(files, 'favicons.zip');
toast.success('Downloading favicons ZIP…');
};
const handleReset = () => {
setSourceFile(null);
setResult(null);
setProgress(0);
setMobileTab('setup');
};
return (
<div className="flex flex-col gap-4">
{/* ── Mobile tab switcher ─────────────────────────────── */}
<div className="flex lg:hidden glass rounded-xl p-1 gap-1">
{(['setup', 'results'] as MobileTab[]).map((t) => (
<button
key={t}
onClick={() => setMobileTab(t)}
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-medium capitalize transition-all',
mobileTab === t
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
{t === 'setup' ? 'Setup' : 'Results'}
</button>
))}
</div>
{/* ── Main layout ─────────────────────────────────────── */}
<div
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
style={{ height: 'calc(100svh - 180px)' }}
>
{/* Left: Setup */}
<div className={cn('lg:col-span-2 flex flex-col gap-3 overflow-hidden', mobileTab !== 'setup' && 'hidden lg:flex')}>
{/* Upload zone */}
<div className="glass rounded-xl p-4 shrink-0">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-3">
Source Image
</span>
<FaviconFileUpload
selectedFile={sourceFile}
onFileSelect={setSourceFile}
onFileRemove={() => setSourceFile(null)}
disabled={isGenerating}
/>
</div>
{/* App config */}
<div className="glass rounded-xl p-4 flex-1 min-h-0 flex flex-col overflow-hidden">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-3 shrink-0">
App Details
</span>
<div className="space-y-3 flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5">
<div>
<label className="text-[10px] text-muted-foreground/60 font-mono mb-1.5 block">App Name</label>
<input
type="text"
value={options.name}
onChange={(e) => setOptions({ ...options, name: e.target.value })}
placeholder="My Awesome App"
className={inputCls}
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground/60 font-mono mb-1.5 block">Short Name</label>
<input
type="text"
value={options.shortName}
onChange={(e) => setOptions({ ...options, shortName: e.target.value })}
placeholder="App"
className={inputCls}
/>
<p className="text-[9px] text-muted-foreground/30 font-mono mt-1">Used for mobile home screen labels</p>
</div>
<div className="space-y-3">
<div>
<label className="text-[10px] text-muted-foreground/60 font-mono mb-1.5 block">Background</label>
<ColorInput
value={options.backgroundColor}
onChange={(v) => setOptions({ ...options, backgroundColor: v })}
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground/60 font-mono mb-1.5 block">Theme</label>
<ColorInput
value={options.themeColor}
onChange={(v) => setOptions({ ...options, themeColor: v })}
/>
</div>
</div>
</div>
{/* Action buttons */}
<div className="flex gap-2 shrink-0 pt-3 mt-3 border-t border-border/25">
{result && (
<button onClick={handleReset} className={cn(actionBtn, 'px-4')}>
Reset
</button>
)}
<button
onClick={handleGenerate}
disabled={!sourceFile || isGenerating}
className={cn(actionBtn, 'flex-1 py-2.5')}
>
{isGenerating
? <><Loader2 className="w-3 h-3 animate-spin" /> Generating {progress}%</>
: 'Generate Favicons'
}
</button>
</div>
</div>
</div>
{/* Right: Results */}
<div className={cn('lg:col-span-3 flex flex-col overflow-hidden', mobileTab !== 'results' && 'hidden lg:flex')}>
<div className="glass rounded-xl p-4 flex flex-col flex-1 min-h-0 overflow-hidden">
{/* Tab bar + download button */}
<div className="flex items-center gap-2 mb-4 shrink-0">
<div className="flex glass rounded-lg p-0.5 gap-0.5 flex-1">
{TABS.map(({ value, label, icon }) => (
<button
key={value}
onClick={() => setTab(value)}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-all',
tab === value
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
{icon}{label}
</button>
))}
</div>
{result && (
<button onClick={handleDownloadAll} className={cn(actionBtn, 'shrink-0 px-3')}>
<Download className="w-3 h-3" />
ZIP
</button>
)}
</div>
{/* Scrollable content */}
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent pr-0.5">
{isGenerating ? (
<div className="flex flex-col items-center justify-center h-full gap-4">
<Loader2 className="w-5 h-5 animate-spin text-primary" />
<div className="w-full max-w-xs space-y-2">
<div className="flex items-center justify-between text-[10px] font-mono text-muted-foreground/50">
<span>Processing</span>
<span className="tabular-nums">{progress}%</span>
</div>
<div className="w-full h-1 rounded-full overflow-hidden bg-white/5">
<div
className="h-full bg-primary/65 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
</div>
) : result ? (
<>
{tab === 'icons' && (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{result.icons.map((icon) => (
<div
key={icon.name}
className="flex flex-col items-center gap-2 p-3 rounded-xl border border-border/20 bg-primary/3 group"
>
<div className="w-14 h-14 rounded-xl border border-border/25 bg-white/4 flex items-center justify-center group-hover:scale-105 transition-transform">
{icon.previewUrl ? (
<img src={icon.previewUrl} alt={icon.name} className="max-w-full max-h-full object-contain" />
) : (
<FileImage className="w-6 h-6 text-muted-foreground/30" />
)}
</div>
<div className="text-center w-full">
<p className="text-[10px] font-mono text-foreground/70 truncate" title={icon.name}>{icon.name}</p>
<p className="text-[9px] font-mono text-muted-foreground/40">{icon.width}×{icon.height} · {(icon.size / 1024).toFixed(1)} KB</p>
</div>
</div>
))}
</div>
)}
{tab === 'html' && (
<div className="space-y-3">
<CodeSnippet code={result.htmlCode} />
<div className="rounded-lg border border-primary/15 bg-primary/5 p-3">
<p className="text-[10px] text-muted-foreground/60 font-mono leading-relaxed">
Place generated files in your site root or update the href paths.
</p>
</div>
</div>
)}
{tab === 'manifest' && (
<CodeSnippet code={result.manifest} />
)}
</>
) : (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
<div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
<FileImage className="w-6 h-6 text-primary/40" />
</div>
<div>
<p className="text-sm font-medium text-foreground/40">No assets yet</p>
<p className="text-[10px] text-muted-foreground/30 font-mono mt-1">Upload an image and generate favicons</p>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}