2026-02-26 17:48:16 +01:00
|
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
|
|
import * as React from 'react';
|
2026-03-01 08:30:56 +01:00
|
|
|
|
import { Download, Loader2, Code2, Globe, Layout, FileImage } from 'lucide-react';
|
2026-02-26 17:48:16 +01:00
|
|
|
|
import { FaviconFileUpload } from './FaviconFileUpload';
|
|
|
|
|
|
import { CodeSnippet } from './CodeSnippet';
|
|
|
|
|
|
import { generateFaviconSet } from '@/lib/favicon/faviconService';
|
|
|
|
|
|
import { downloadBlobsAsZip } from '@/lib/media/utils/fileUtils';
|
|
|
|
|
|
import type { FaviconSet, FaviconOptions } from '@/types/favicon';
|
|
|
|
|
|
import { toast } from 'sonner';
|
2026-03-01 08:30:56 +01:00
|
|
|
|
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';
|
2026-02-26 17:48:16 +01:00
|
|
|
|
|
|
|
|
|
|
export function FaviconGenerator() {
|
|
|
|
|
|
const [sourceFile, setSourceFile] = React.useState<File | null>(null);
|
|
|
|
|
|
const [options, setOptions] = React.useState<FaviconOptions>({
|
2026-03-01 08:30:56 +01:00
|
|
|
|
name: 'My App',
|
2026-02-26 17:48:16 +01:00
|
|
|
|
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);
|
2026-03-01 08:30:56 +01:00
|
|
|
|
const [tab, setTab] = React.useState<Tab>('icons');
|
|
|
|
|
|
const [mobileTab, setMobileTab] = React.useState<MobileTab>('setup');
|
2026-02-26 17:48:16 +01:00
|
|
|
|
|
|
|
|
|
|
const handleGenerate = async () => {
|
2026-03-01 08:30:56 +01:00
|
|
|
|
if (!sourceFile) { toast.error('Please upload a source image'); return; }
|
2026-02-26 17:48:16 +01:00
|
|
|
|
setIsGenerating(true);
|
|
|
|
|
|
setProgress(0);
|
|
|
|
|
|
try {
|
2026-03-01 08:30:56 +01:00
|
|
|
|
const resultSet = await generateFaviconSet(sourceFile, options, (p) => setProgress(p));
|
2026-02-26 17:48:16 +01:00
|
|
|
|
setResult(resultSet);
|
2026-03-01 08:30:56 +01:00
|
|
|
|
setMobileTab('results');
|
|
|
|
|
|
toast.success('Favicon set generated!');
|
2026-02-26 17:48:16 +01:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(error);
|
|
|
|
|
|
toast.error('Failed to generate favicons');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsGenerating(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleDownloadAll = async () => {
|
|
|
|
|
|
if (!result) return;
|
2026-03-01 08:30:56 +01:00
|
|
|
|
const files = result.icons.map((icon) => ({ blob: icon.blob!, filename: icon.name }));
|
2026-02-26 17:48:16 +01:00
|
|
|
|
const manifestBlob = new Blob([result.manifest], { type: 'application/json' });
|
2026-03-01 08:30:56 +01:00
|
|
|
|
files.push({ blob: manifestBlob, filename: 'site.webmanifest' });
|
2026-02-26 17:48:16 +01:00
|
|
|
|
await downloadBlobsAsZip(files, 'favicons.zip');
|
2026-03-01 08:30:56 +01:00
|
|
|
|
toast.success('Downloading favicons ZIP…');
|
2026-02-26 17:48:16 +01:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleReset = () => {
|
|
|
|
|
|
setSourceFile(null);
|
|
|
|
|
|
setResult(null);
|
|
|
|
|
|
setProgress(0);
|
2026-03-01 08:30:56 +01:00
|
|
|
|
setMobileTab('setup');
|
2026-02-26 17:48:16 +01:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-01 08:30:56 +01:00
|
|
|
|
<div className="flex flex-col gap-4">
|
2026-02-26 17:48:16 +01:00
|
|
|
|
|
2026-03-01 08:30:56 +01:00
|
|
|
|
{/* ── 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"
|
2026-03-01 12:20:15 +01:00
|
|
|
|
style={{ height: 'calc(100svh - 180px)' }}
|
2026-03-01 08:30:56 +01:00
|
|
|
|
>
|
2026-02-26 17:48:16 +01:00
|
|
|
|
|
2026-03-01 08:30:56 +01:00
|
|
|
|
{/* 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>
|
2026-02-26 17:48:16 +01:00
|
|
|
|
<FaviconFileUpload
|
|
|
|
|
|
selectedFile={sourceFile}
|
|
|
|
|
|
onFileSelect={setSourceFile}
|
|
|
|
|
|
onFileRemove={() => setSourceFile(null)}
|
|
|
|
|
|
disabled={isGenerating}
|
|
|
|
|
|
/>
|
2026-03-01 08:30:56 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* App config */}
|
2026-03-01 08:33:28 +01:00
|
|
|
|
<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">
|
2026-03-01 08:30:56 +01:00
|
|
|
|
App Details
|
|
|
|
|
|
</span>
|
2026-03-01 08:33:28 +01:00
|
|
|
|
<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">
|
2026-03-01 08:30:56 +01:00
|
|
|
|
<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="grid grid-cols-2 gap-3">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="text-[10px] text-muted-foreground/60 font-mono mb-1.5 block">Background</label>
|
|
|
|
|
|
<div className="flex gap-1.5">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="color"
|
|
|
|
|
|
value={options.backgroundColor}
|
|
|
|
|
|
onChange={(e) => setOptions({ ...options, backgroundColor: 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={options.backgroundColor}
|
|
|
|
|
|
onChange={(e) => setOptions({ ...options, backgroundColor: e.target.value })}
|
|
|
|
|
|
className={cn(inputCls, 'py-1')}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="text-[10px] text-muted-foreground/60 font-mono mb-1.5 block">Theme</label>
|
|
|
|
|
|
<div className="flex gap-1.5">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="color"
|
|
|
|
|
|
value={options.themeColor}
|
|
|
|
|
|
onChange={(e) => setOptions({ ...options, themeColor: 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={options.themeColor}
|
|
|
|
|
|
onChange={(e) => setOptions({ ...options, themeColor: e.target.value })}
|
|
|
|
|
|
className={cn(inputCls, 'py-1')}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-01 08:33:28 +01:00
|
|
|
|
{/* 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'
|
|
|
|
|
|
}
|
2026-03-01 08:30:56 +01:00
|
|
|
|
</button>
|
2026-03-01 08:33:28 +01:00
|
|
|
|
</div>
|
2026-03-01 08:30:56 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-26 17:48:16 +01:00
|
|
|
|
|
2026-03-01 08:30:56 +01:00
|
|
|
|
{/* 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>
|
|
|
|
|
|
))}
|
2026-02-26 17:48:16 +01:00
|
|
|
|
</div>
|
2026-03-01 08:30:56 +01:00
|
|
|
|
{result && (
|
|
|
|
|
|
<button onClick={handleDownloadAll} className={cn(actionBtn, 'shrink-0 px-3')}>
|
|
|
|
|
|
<Download className="w-3 h-3" />
|
|
|
|
|
|
ZIP
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2026-02-26 17:48:16 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-01 08:30:56 +01:00
|
|
|
|
{/* 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>
|
2026-02-26 17:48:16 +01:00
|
|
|
|
</div>
|
2026-03-01 08:30:56 +01:00
|
|
|
|
))}
|
|
|
|
|
|
</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>
|
2026-02-26 17:48:16 +01:00
|
|
|
|
</div>
|
2026-03-01 08:30:56 +01:00
|
|
|
|
</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>
|
2026-02-26 17:48:16 +01:00
|
|
|
|
</div>
|
2026-03-01 08:30:56 +01:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-02-26 17:48:16 +01:00
|
|
|
|
</div>
|
2026-03-01 08:30:56 +01:00
|
|
|
|
</div>
|
2026-02-26 17:48:16 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|