refactor: align favicon tool with Calculate/Media blueprint
- FaviconGenerator: lg:grid-cols-5 layout (2/5 setup, 3/5 results); glass panels, native inputs, custom tab switcher (Icons/HTML/Manifest), native progress bar, empty state placeholder, mobile Setup|Results tabs - FaviconFileUpload: remove shadcn Button; match media FileUpload styling with file card, metadata chips (size, dimensions) - CodeSnippet: remove shadcn Button; dark terminal (#06060e) with hover copy button, consistent with ASCII/ExportMenu code blocks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,15 +2,13 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CodeSnippetProps {
|
||||
code: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export function CodeSnippet({ code, language }: CodeSnippetProps) {
|
||||
export function CodeSnippet({ code }: CodeSnippetProps) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
@@ -21,18 +19,15 @@ export function CodeSnippet({ code, language }: CodeSnippetProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<div className="absolute right-4 top-4 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon-xs"
|
||||
<div className="relative group rounded-xl overflow-hidden border border-white/5" style={{ background: '#06060e' }}>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="bg-background/50 backdrop-blur-md border border-border"
|
||||
className="absolute right-3 top-3 opacity-0 group-hover:opacity-100 flex items-center gap-1 px-2 py-1 text-[10px] font-mono rounded-md border border-white/10 bg-white/5 text-white/40 hover:text-white/70 hover:border-white/20 transition-all"
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="p-4 rounded-lg bg-input backdrop-blur-sm border border-border overflow-x-auto font-mono text-xs text-muted-foreground leading-relaxed">
|
||||
{copied ? <Check className="w-2.5 h-2.5" /> : <Copy className="w-2.5 h-2.5" />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
<pre className="p-4 overflow-x-auto font-mono text-[11px] text-white/55 leading-relaxed">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Upload, X, FileImage, HardDrive } from 'lucide-react';
|
||||
import { Upload, X, FileImage, HardDrive, Film } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export interface FaviconFileUploadProps {
|
||||
onFileSelect: (file: File) => void;
|
||||
@@ -26,7 +25,7 @@ export function FaviconFileUpload({
|
||||
if (selectedFile) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setDimensions(`${img.width} × ${img.height}`);
|
||||
setDimensions(`${img.width}×${img.height}`);
|
||||
URL.revokeObjectURL(img.src);
|
||||
};
|
||||
img.src = URL.createObjectURL(selectedFile);
|
||||
@@ -35,49 +34,22 @@ export function FaviconFileUpload({
|
||||
}
|
||||
}, [selectedFile]);
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled) setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0 && files[0].type.startsWith('image/')) {
|
||||
onFileSelect(files[0]);
|
||||
}
|
||||
if (files.length > 0 && files[0].type.startsWith('image/')) onFileSelect(files[0]);
|
||||
};
|
||||
|
||||
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length > 0 && files[0].type.startsWith('image/')) {
|
||||
onFileSelect(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (!disabled) fileInputRef.current?.click();
|
||||
if (files.length > 0 && files[0].type.startsWith('image/')) onFileSelect(files[0]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-3">
|
||||
<div className="w-full">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -88,66 +60,64 @@ export function FaviconFileUpload({
|
||||
/>
|
||||
|
||||
{selectedFile ? (
|
||||
<div className="border border-border rounded-xl p-4 bg-card/50 backdrop-blur-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-primary/10 rounded-lg shrink-0">
|
||||
<FileImage className="h-5 w-5 text-primary" />
|
||||
<div className="flex items-start gap-3 p-3 rounded-xl border border-border/25 bg-primary/3">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<FileImage className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium text-foreground truncate" title={selectedFile.name}>
|
||||
<p className="text-xs font-mono text-foreground/80 truncate" title={selectedFile.name}>
|
||||
{selectedFile.name}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
<button
|
||||
onClick={onFileRemove}
|
||||
disabled={disabled}
|
||||
className="rounded-full hover:bg-destructive/10 hover:text-destructive shrink-0"
|
||||
className="shrink-0 w-5 h-5 flex items-center justify-center rounded text-muted-foreground/30 hover:text-rose-400 transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-1.5 flex gap-3 text-[10px] text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
<span>{(selectedFile.size / 1024).toFixed(1)} KB</span>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2.5 text-[10px] text-muted-foreground/40 font-mono">
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="w-2.5 h-2.5" />
|
||||
{selectedFile.size < 1024 * 1024
|
||||
? `${(selectedFile.size / 1024).toFixed(1)} KB`
|
||||
: `${(selectedFile.size / (1024 * 1024)).toFixed(1)} MB`}
|
||||
</span>
|
||||
{dimensions && (
|
||||
<div className="flex items-center gap-1">
|
||||
<FileImage className="h-3 w-3" />
|
||||
<span>{dimensions}</span>
|
||||
</div>
|
||||
<span className="flex items-center gap-1">
|
||||
<Film className="w-2.5 h-2.5" />{dimensions}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => !disabled && fileInputRef.current?.click()}
|
||||
onDragEnter={(e) => { e.preventDefault(); if (!disabled) setIsDragging(true); }}
|
||||
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all duration-200',
|
||||
'hover:border-primary/40 hover:bg-primary/5',
|
||||
{
|
||||
'border-primary bg-primary/10 scale-[0.98]': isDragging,
|
||||
'border-border/50': !isDragging,
|
||||
'opacity-50 cursor-not-allowed': disabled,
|
||||
}
|
||||
'flex flex-col items-center justify-center rounded-xl border-2 border-dashed transition-all cursor-pointer text-center select-none py-8',
|
||||
isDragging
|
||||
? 'border-primary bg-primary/10 scale-[0.99]'
|
||||
: 'border-border/35 hover:border-primary/40 hover:bg-primary/5',
|
||||
disabled && 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<div className="bg-primary/10 w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Upload className="h-6 w-6 text-primary" />
|
||||
<div className={cn(
|
||||
'w-14 h-14 rounded-full flex items-center justify-center mb-4 transition-colors',
|
||||
isDragging ? 'bg-primary/25' : 'bg-primary/10'
|
||||
)}>
|
||||
<Upload className={cn('w-6 h-6 transition-colors', isDragging ? 'text-primary' : 'text-primary/60')} />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground mb-0.5">
|
||||
Drop icon source here
|
||||
<p className="text-sm font-medium text-foreground/70 mb-1">
|
||||
{isDragging ? 'Drop to upload' : 'Drop icon here or click to browse'}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
512x512 PNG or SVG recommended
|
||||
<p className="text-[10px] text-muted-foreground/35 font-mono">
|
||||
PNG · SVG · 512×512 recommended
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Download, Loader2, Code2, Globe, Layout } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Download, Loader2, Code2, Globe, Layout, FileImage } from 'lucide-react';
|
||||
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';
|
||||
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 Awesome App',
|
||||
name: 'My App',
|
||||
shortName: 'App',
|
||||
backgroundColor: '#ffffff',
|
||||
themeColor: '#3b82f6',
|
||||
@@ -26,22 +36,18 @@ export function FaviconGenerator() {
|
||||
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;
|
||||
}
|
||||
|
||||
if (!sourceFile) { toast.error('Please upload a source image'); return; }
|
||||
setIsGenerating(true);
|
||||
setProgress(0);
|
||||
|
||||
try {
|
||||
const resultSet = await generateFaviconSet(sourceFile, options, (p) => {
|
||||
setProgress(p);
|
||||
});
|
||||
const resultSet = await generateFaviconSet(sourceFile, options, (p) => setProgress(p));
|
||||
setResult(resultSet);
|
||||
toast.success('Favicon set generated successfully!');
|
||||
setMobileTab('results');
|
||||
toast.success('Favicon set generated!');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Failed to generate favicons');
|
||||
@@ -52,229 +58,249 @@ export function FaviconGenerator() {
|
||||
|
||||
const handleDownloadAll = async () => {
|
||||
if (!result) return;
|
||||
|
||||
const files = result.icons.map((icon) => ({
|
||||
blob: icon.blob!,
|
||||
filename: icon.name,
|
||||
}));
|
||||
|
||||
// Add manifest to ZIP
|
||||
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',
|
||||
});
|
||||
|
||||
files.push({ blob: manifestBlob, filename: 'site.webmanifest' });
|
||||
await downloadBlobsAsZip(files, 'favicons.zip');
|
||||
toast.success('Downloading favicons ZIP...');
|
||||
toast.success('Downloading favicons ZIP…');
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSourceFile(null);
|
||||
setResult(null);
|
||||
setProgress(0);
|
||||
setMobileTab('setup');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
{/* Settings Column */}
|
||||
<div className="lg:col-span-4 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>App Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="app-name" className="text-xs">Application Name</Label>
|
||||
<Input
|
||||
id="app-name"
|
||||
value={options.name}
|
||||
onChange={(e) => setOptions({ ...options, name: e.target.value })}
|
||||
placeholder="e.g. My Awesome Website"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="short-name" className="text-xs">Short Name</Label>
|
||||
<Input
|
||||
id="short-name"
|
||||
value={options.shortName}
|
||||
onChange={(e) => setOptions({ ...options, shortName: e.target.value })}
|
||||
placeholder="e.g. My App"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">Used for mobile home screen labels</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Theme Colors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="bg-color" className="text-xs">Background</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="bg-color"
|
||||
type="color"
|
||||
className="w-9 p-1 h-9 shrink-0"
|
||||
value={options.backgroundColor}
|
||||
onChange={(e) => setOptions({ ...options, backgroundColor: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
className="font-mono text-xs"
|
||||
value={options.backgroundColor}
|
||||
onChange={(e) => setOptions({ ...options, backgroundColor: e.target.value })}
|
||||
/>
|
||||
{/* ── 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>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="theme-color" className="text-xs">Theme</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="theme-color"
|
||||
type="color"
|
||||
className="w-9 p-1 h-9 shrink-0"
|
||||
value={options.themeColor}
|
||||
onChange={(e) => setOptions({ ...options, themeColor: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
className="font-mono text-xs"
|
||||
value={options.themeColor}
|
||||
onChange={(e) => setOptions({ ...options, themeColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent>
|
||||
{/* ── Main layout ─────────────────────────────────────── */}
|
||||
<div
|
||||
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
|
||||
style={{ height: 'calc(100svh - 220px)', minHeight: '620px' }}
|
||||
>
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
<Button
|
||||
className="w-full mt-4"
|
||||
disabled={!sourceFile || isGenerating}
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
Generating... {progress}%
|
||||
</>
|
||||
) : (
|
||||
'Generate Favicons'
|
||||
)}
|
||||
</Button>
|
||||
{result && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mt-2"
|
||||
onClick={handleReset}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Results Column */}
|
||||
<div className="lg:col-span-8 space-y-6">
|
||||
{isGenerating ? (
|
||||
<Card className="h-full flex flex-col items-center justify-center p-10 space-y-4">
|
||||
<Loader2 className="h-6 w-6 text-primary animate-spin" />
|
||||
<div className="w-full max-w-xs space-y-2">
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium">Processing...</span>
|
||||
</div>
|
||||
<span className="tabular-nums">{progress}%</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-1" />
|
||||
</div>
|
||||
</Card>
|
||||
) : result ? (
|
||||
<div className="space-y-5 animate-fade-in">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-bold">Generated Assets</h2>
|
||||
<Button onClick={handleDownloadAll}>
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
Download ZIP
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="icons" className="w-full">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="icons" className="flex items-center gap-1.5">
|
||||
<Layout className="h-3.5 w-3.5" />
|
||||
Icons
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="html" className="flex items-center gap-1.5">
|
||||
<Code2 className="h-3.5 w-3.5" />
|
||||
HTML
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="manifest" className="flex items-center gap-1.5">
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
Manifest
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="icons" className="mt-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{result?.icons.map((icon) => (
|
||||
<Card key={icon.name} className="group overflow-hidden">
|
||||
<div className="p-3 flex flex-col items-center text-center space-y-2">
|
||||
<div className="relative h-16 w-16 flex items-center justify-center bg-muted/50 rounded-lg p-1.5 border border-border/50 group-hover:scale-105 transition-transform duration-200">
|
||||
{icon.previewUrl && (
|
||||
<img
|
||||
src={icon.previewUrl}
|
||||
alt={icon.name}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
{/* App config */}
|
||||
<div className="glass rounded-xl p-4 shrink-0">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest block mb-3">
|
||||
App Details
|
||||
</span>
|
||||
<div className="space-y-3">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2 shrink-0 mt-auto">
|
||||
{result && (
|
||||
<button onClick={handleReset} className={cn(actionBtn, 'px-4')}>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-0.5 w-full">
|
||||
<p className="text-[10px] font-medium text-foreground truncate" title={icon.name}>
|
||||
{icon.name}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{icon.width}x{icon.height} · {(icon.size / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
{/* 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>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="html" className="mt-4 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Embed in your <head></Label>
|
||||
{result && <CodeSnippet code={result.htmlCode} />}
|
||||
{result && (
|
||||
<button onClick={handleDownloadAll} className={cn(actionBtn, 'shrink-0 px-3')}>
|
||||
<Download className="w-3 h-3" />
|
||||
ZIP
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-primary/5 border border-primary/10">
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
Place generated files in your site root or update the <code className="text-primary">href</code> paths.
|
||||
|
||||
{/* 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>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manifest" className="mt-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">site.webmanifest</Label>
|
||||
{result && <CodeSnippet code={result.manifest} />}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
{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>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user