feat: add Favicon Generator app with ImageMagick WASM support

This commit is contained in:
2026-02-26 17:48:16 +01:00
parent d99c88df0e
commit 1f1b138089
16 changed files with 987 additions and 5 deletions

View File

@@ -0,0 +1,19 @@
'use client';
import * as React from 'react';
import { AppPage } from '@/components/layout/AppPage';
import { FaviconGenerator } from '@/components/favicon/FaviconGenerator';
import { Globe, Shield, Zap } from 'lucide-react';
export default function FaviconPage() {
return (
<AppPage
title="Favicon Generator"
description="Create a complete set of icons for your website including PWA manifest and HTML code."
>
<div className="w-full max-w-7xl mx-auto space-y-8 pb-12">
<FaviconGenerator />
</div>
</AppPage>
);
}

View File

@@ -199,6 +199,10 @@ html {
background: linear-gradient(135deg, #10b981 0%, #06b6d4 100%);
}
@utility gradient-blue-cyan {
background: linear-gradient(135deg, #3b82f6 0%, #06b6d4 100%);
}
@utility gradient-brand {
background: linear-gradient(to right, #a78bfa, #f472b6, #22d3ee);
}

View File

@@ -30,3 +30,9 @@ export const MediaIcon = (props: React.SVGProps<SVGSVGElement>) => (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
);
export const FaviconIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
);

View File

@@ -4,7 +4,7 @@ import { motion } from 'framer-motion';
const stats = [
{
number: '4',
number: '5',
label: 'Tools',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -2,7 +2,7 @@
import { motion } from 'framer-motion';
import ToolCard from './ToolCard';
import { ColorIcon, UnitsIcon, ASCIIIcon, MediaIcon } from '@/components/AppIcons';
import { ColorIcon, UnitsIcon, ASCIIIcon, MediaIcon, FaviconIcon } from '@/components/AppIcons';
const tools = [
{
@@ -41,6 +41,15 @@ const tools = [
badges: ['Open Source', 'Converter', 'Free'],
icon: <MediaIcon className="w-12 h-12 text-white" />,
},
{
title: 'Favicon',
description: 'Generate a complete set of favicons for your website. Includes PWA manifest and HTML embed code. All processing happens locally in your browser.',
url: '/favicon',
gradient: 'gradient-blue-cyan',
accentColor: '#3b82f6',
badges: ['Open Source', 'Generator', 'Free'],
icon: <FaviconIcon className="w-12 h-12 text-white" />,
},
];
export default function ToolsGrid() {
@@ -64,7 +73,7 @@ export default function ToolsGrid() {
</motion.div>
{/* Tools grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{tools.map((tool, index) => (
<ToolCard
key={tool.title}

View File

@@ -0,0 +1,40 @@
'use client';
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) {
const [copied, setCopied] = React.useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(code);
setCopied(true);
toast.success('Copied to clipboard');
setTimeout(() => setCopied(false), 2000);
};
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"
onClick={handleCopy}
className="bg-background/50 backdrop-blur-md border border-border"
>
{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">
<code>{code}</code>
</pre>
</div>
);
}

View File

@@ -0,0 +1,156 @@
'use client';
import * as React from 'react';
import { Upload, X, FileImage, HardDrive } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { Button } from '@/components/ui/button';
export interface FaviconFileUploadProps {
onFileSelect: (file: File) => void;
onFileRemove: () => void;
selectedFile?: File | null;
disabled?: boolean;
}
export function FaviconFileUpload({
onFileSelect,
onFileRemove,
selectedFile,
disabled = false,
}: FaviconFileUploadProps) {
const [isDragging, setIsDragging] = React.useState(false);
const [dimensions, setDimensions] = React.useState<string | null>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (selectedFile) {
const img = new Image();
img.onload = () => {
setDimensions(`${img.width} × ${img.height}`);
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(selectedFile);
} else {
setDimensions(null);
}
}, [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]);
}
};
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();
};
return (
<div className="w-full space-y-3">
<input
ref={fileInputRef}
type="file"
className="hidden"
accept="image/*"
onChange={handleFileInput}
disabled={disabled}
/>
{selectedFile ? (
<div className="border border-border rounded-lg p-4 bg-card/50 backdrop-blur-sm">
<div className="flex items-start gap-4">
<div className="p-2 bg-primary/10 rounded-lg">
<FileImage className="h-6 w-6 text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-semibold text-foreground truncate" title={selectedFile.name}>
{selectedFile.name}
</p>
<Button
variant="ghost"
size="icon-xs"
onClick={onFileRemove}
disabled={disabled}
className="rounded-full hover:bg-destructive/10 hover:text-destructive"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<div className="mt-2 flex gap-4 text-[10px] text-muted-foreground uppercase tracking-wider font-bold">
<div className="flex items-center gap-1.5">
<HardDrive className="h-3 w-3" />
<span>{(selectedFile.size / 1024).toFixed(1)} KB</span>
</div>
{dimensions && (
<div className="flex items-center gap-1.5">
<FileImage className="h-3 w-3" />
<span>{dimensions}</span>
</div>
)}
</div>
</div>
</div>
</div>
) : (
<div
onClick={handleClick}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-all duration-300',
'hover:border-primary/50 hover:bg-primary/5',
{
'border-primary bg-primary/10 scale-[0.98]': isDragging,
'border-border bg-muted/30': !isDragging,
'opacity-50 cursor-not-allowed': disabled,
}
)}
>
<div className="bg-primary/10 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<Upload className="h-8 w-8 text-primary" />
</div>
<p className="text-sm font-semibold text-foreground mb-1">
Drop icon source here
</p>
<p className="text-xs text-muted-foreground">
Recommended: 512x512 PNG or SVG
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,301 @@
'use client';
import * as React from 'react';
import { Download, Loader2, RefreshCw, Code2, Globe, Layout, Palette } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } 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 { 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';
export function FaviconGenerator() {
const [sourceFile, setSourceFile] = React.useState<File | null>(null);
const [options, setOptions] = React.useState<FaviconOptions>({
name: 'My Awesome 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 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);
toast.success('Favicon set generated successfully!');
} 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,
}));
// Add manifest to ZIP
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);
};
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Settings Column */}
<div className="lg:col-span-4 space-y-6">
<Card className="glass">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Layout className="h-5 w-5 text-primary" />
App Details
</CardTitle>
<CardDescription>
Configure how your app appears on devices
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="app-name">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-2">
<Label htmlFor="short-name">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>
<Card className="glass">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Palette className="h-5 w-5 text-primary" />
Theme Colors
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="bg-color">Background</Label>
<div className="flex gap-2">
<Input
id="bg-color"
type="color"
className="w-10 p-1 h-10 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 })}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="theme-color">Theme</Label>
<div className="flex gap-2">
<Input
id="theme-color"
type="color"
className="w-10 p-1 h-10 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="glass overflow-hidden">
<CardContent className="p-6">
<FaviconFileUpload
selectedFile={sourceFile}
onFileSelect={setSourceFile}
onFileRemove={() => setSourceFile(null)}
disabled={isGenerating}
/>
<Button
className="w-full mt-6"
size="lg"
disabled={!sourceFile || isGenerating}
onClick={handleGenerate}
>
{isGenerating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating... {progress}%
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
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">
{!result && !isGenerating ? (
<Card className="glass h-full flex flex-col items-center justify-center p-12 text-center text-muted-foreground border-dashed border-2">
<Globe className="h-12 w-12 mb-4 opacity-20" />
<h3 className="text-lg font-semibold text-foreground">Ready to generate</h3>
<p className="max-w-xs text-sm">Upload a square image (PNG or SVG recommended) and configure your app details to get started.</p>
</Card>
) : isGenerating ? (
<Card className="glass h-full flex flex-col items-center justify-center p-12 space-y-6">
<div className="relative">
<div className="h-24 w-24 rounded-full border-4 border-primary/20 animate-pulse" />
<Loader2 className="h-12 w-12 text-primary animate-spin absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
</div>
<div className="w-full max-w-sm space-y-2">
<div className="flex justify-between text-xs font-bold uppercase tracking-widest text-muted-foreground">
<span>Processing Icons</span>
<span>{progress}%</span>
</div>
<Progress value={progress} className="h-1.5" />
</div>
</Card>
) : (
<div className="space-y-6 animate-fadeIn">
<div className="flex items-center justify-between gap-4">
<h2 className="text-2xl font-bold tracking-tight">Generated Assets</h2>
<Button onClick={handleDownloadAll} variant="default" className="shadow-lg shadow-primary/20">
<Download className="mr-2 h-4 w-4" />
Download All (ZIP)
</Button>
</div>
<Tabs defaultValue="icons" className="w-full">
<TabsList className="w-full">
<TabsTrigger value="icons" className="flex items-center gap-2">
<Layout className="h-4 w-4" />
Icons
</TabsTrigger>
<TabsTrigger value="html" className="flex items-center gap-2">
<Code2 className="h-4 w-4" />
HTML Code
</TabsTrigger>
<TabsTrigger value="manifest" className="flex items-center gap-2">
<Globe className="h-4 w-4" />
Manifest
</TabsTrigger>
</TabsList>
<TabsContent value="icons" className="mt-6">
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{result?.icons.map((icon) => (
<Card key={icon.name} className="glass group overflow-hidden">
<div className="p-4 flex flex-col items-center text-center space-y-3">
<div className="relative h-20 w-20 flex items-center justify-center bg-black/20 rounded-lg p-2 border border-border group-hover:scale-110 transition-transform duration-300">
{icon.previewUrl && (
<img
src={icon.previewUrl}
alt={icon.name}
className="max-w-full max-h-full object-contain"
/>
)}
</div>
<div className="space-y-1 w-full">
<p className="text-[10px] font-bold text-foreground truncate uppercase tracking-tighter" 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>
</div>
</div>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="html" className="mt-6 space-y-4">
<div className="space-y-2">
<Label>Embed in your &lt;head&gt;</Label>
{result && <CodeSnippet code={result.htmlCode} />}
</div>
<div className="p-4 rounded-lg bg-info/5 border border-info/20">
<p className="text-xs text-info leading-relaxed">
<strong>Note:</strong> Make sure to place the generated files in your website&apos;s root directory or update the <code>href</code> paths accordingly.
</p>
</div>
</TabsContent>
<TabsContent value="manifest" className="mt-6">
<div className="space-y-2">
<Label>site.webmanifest</Label>
{result && <CodeSnippet code={result.manifest} />}
</div>
</TabsContent>
</Tabs>
</div>
)}
</div>
</div>
);
}

View File

@@ -17,7 +17,7 @@ import { cn } from '@/lib/utils/cn';
import Logo from '@/components/Logo';
import { useSidebar } from './SidebarProvider';
import { Button } from '@/components/ui/button';
import { ColorIcon, UnitsIcon, ASCIIIcon, MediaIcon } from '@/components/AppIcons';
import { ColorIcon, UnitsIcon, ASCIIIcon, MediaIcon, FaviconIcon } from '@/components/AppIcons';
interface NavItem {
title: string;
@@ -55,6 +55,11 @@ const navigation: NavGroup[] = [
href: '/media',
icon: <MediaIcon className="h-4 w-4" />
},
{
title: 'Favicon Generator',
href: '/favicon',
icon: <FaviconIcon className="h-4 w-4" />
},
]
}
];

View File

@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'sonner';
import { useState } from 'react';
import { ThemeProvider } from './ThemeProvider';
import { TooltipProvider } from '@/components/ui/tooltip';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
@@ -21,7 +22,9 @@ export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<QueryClientProvider client={queryClient}>
{children}
<TooltipProvider>
{children}
</TooltipProvider>
<Toaster position="top-right" richColors />
</QueryClientProvider>
</ThemeProvider>

158
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils/index"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

24
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils/index"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

91
components/ui/tabs.tsx Normal file
View File

@@ -0,0 +1,91 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils/index"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

57
components/ui/tooltip.tsx Normal file
View File

@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils/index"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,86 @@
import { resizeImage } from '../media/converters/imagemagickService';
import type { FaviconIcon, FaviconSet, FaviconOptions } from '@/types/favicon';
export const ICON_CONFIGS = [
{ name: 'favicon-16x16.png', width: 16, height: 16 },
{ name: 'favicon-32x32.png', width: 32, height: 32 },
{ name: 'apple-touch-icon.png', width: 180, height: 180 },
{ name: 'android-chrome-192x192.png', width: 192, height: 192 },
{ name: 'android-chrome-512x512.png', width: 512, height: 512 },
{ name: 'favicon.ico', width: 48, height: 48 }, // Simple ICO fallback
];
export async function generateFaviconSet(
file: File,
options: FaviconOptions,
onProgress?: (progress: number) => void
): Promise<FaviconSet> {
const icons: FaviconIcon[] = [];
const totalSteps = ICON_CONFIGS.length;
for (let i = 0; i < ICON_CONFIGS.length; i++) {
const config = ICON_CONFIGS[i];
const format = config.name.endsWith('.ico') ? 'ico' : 'png';
const result = await resizeImage(file, config.width, config.height, format);
if (result.success && result.blob) {
icons.push({
name: config.name,
width: config.width,
height: config.height,
size: result.blob.size,
blob: result.blob,
previewUrl: URL.createObjectURL(result.blob),
});
}
if (onProgress) {
onProgress(Math.round(((i + 1) / totalSteps) * 100));
}
}
const manifest = generateManifest(options);
const htmlCode = generateHtmlCode(options);
return {
icons,
manifest,
htmlCode,
};
}
function generateManifest(options: FaviconOptions): string {
const manifest = {
name: options.name,
short_name: options.shortName,
icons: [
{
src: '/android-chrome-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/android-chrome-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
theme_color: options.themeColor,
background_color: options.backgroundColor,
display: 'standalone',
};
return JSON.stringify(manifest, null, 2);
}
function generateHtmlCode(options: FaviconOptions): string {
return `
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<meta name="msapplication-TileColor" content="${options.backgroundColor}">
<meta name="theme-color" content="${options.themeColor}">
`.trim();
}

23
types/favicon.ts Normal file
View File

@@ -0,0 +1,23 @@
import { ConversionResult } from './media';
export interface FaviconIcon {
name: string;
size: number;
width: number;
height: number;
blob?: Blob;
previewUrl?: string;
}
export interface FaviconSet {
icons: FaviconIcon[];
manifest: string;
htmlCode: string;
}
export interface FaviconOptions {
name: string;
shortName: string;
backgroundColor: string;
themeColor: string;
}