From 1f1b138089453bf8819883e6740918f10ea00dfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Thu, 26 Feb 2026 17:48:16 +0100 Subject: [PATCH] feat: add Favicon Generator app with ImageMagick WASM support --- app/(app)/favicon/page.tsx | 19 ++ app/globals.css | 4 + components/AppIcons.tsx | 6 + components/Stats.tsx | 2 +- components/ToolsGrid.tsx | 13 +- components/favicon/CodeSnippet.tsx | 40 +++ components/favicon/FaviconFileUpload.tsx | 156 ++++++++++++ components/favicon/FaviconGenerator.tsx | 301 +++++++++++++++++++++++ components/layout/AppSidebar.tsx | 7 +- components/providers/Providers.tsx | 5 +- components/ui/dialog.tsx | 158 ++++++++++++ components/ui/label.tsx | 24 ++ components/ui/tabs.tsx | 91 +++++++ components/ui/tooltip.tsx | 57 +++++ lib/favicon/faviconService.ts | 86 +++++++ types/favicon.ts | 23 ++ 16 files changed, 987 insertions(+), 5 deletions(-) create mode 100644 app/(app)/favicon/page.tsx create mode 100644 components/favicon/CodeSnippet.tsx create mode 100644 components/favicon/FaviconFileUpload.tsx create mode 100644 components/favicon/FaviconGenerator.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 lib/favicon/faviconService.ts create mode 100644 types/favicon.ts diff --git a/app/(app)/favicon/page.tsx b/app/(app)/favicon/page.tsx new file mode 100644 index 0000000..f337577 --- /dev/null +++ b/app/(app)/favicon/page.tsx @@ -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 ( + +
+ +
+
+ ); +} diff --git a/app/globals.css b/app/globals.css index 5448156..442d8b7 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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); } diff --git a/components/AppIcons.tsx b/components/AppIcons.tsx index b8e1bda..ccebc0a 100644 --- a/components/AppIcons.tsx +++ b/components/AppIcons.tsx @@ -30,3 +30,9 @@ export const MediaIcon = (props: React.SVGProps) => ( ); + +export const FaviconIcon = (props: React.SVGProps) => ( + + + +); diff --git a/components/Stats.tsx b/components/Stats.tsx index 453e74a..048ae38 100644 --- a/components/Stats.tsx +++ b/components/Stats.tsx @@ -4,7 +4,7 @@ import { motion } from 'framer-motion'; const stats = [ { - number: '4', + number: '5', label: 'Tools', icon: ( diff --git a/components/ToolsGrid.tsx b/components/ToolsGrid.tsx index 8f738b5..63a9a2f 100644 --- a/components/ToolsGrid.tsx +++ b/components/ToolsGrid.tsx @@ -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: , }, + { + 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: , + }, ]; export default function ToolsGrid() { @@ -64,7 +73,7 @@ export default function ToolsGrid() { {/* Tools grid */} -
+
{tools.map((tool, index) => ( { + navigator.clipboard.writeText(code); + setCopied(true); + toast.success('Copied to clipboard'); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+ +
+
+        {code}
+      
+
+ ); +} diff --git a/components/favicon/FaviconFileUpload.tsx b/components/favicon/FaviconFileUpload.tsx new file mode 100644 index 0000000..f191958 --- /dev/null +++ b/components/favicon/FaviconFileUpload.tsx @@ -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(null); + const fileInputRef = React.useRef(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) => { + 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 ( +
+ + + {selectedFile ? ( +
+
+
+ +
+
+
+

+ {selectedFile.name} +

+ +
+
+
+ + {(selectedFile.size / 1024).toFixed(1)} KB +
+ {dimensions && ( +
+ + {dimensions} +
+ )} +
+
+
+
+ ) : ( +
+
+ +
+

+ Drop icon source here +

+

+ Recommended: 512x512 PNG or SVG +

+
+ )} +
+ ); +} diff --git a/components/favicon/FaviconGenerator.tsx b/components/favicon/FaviconGenerator.tsx new file mode 100644 index 0000000..5f3e355 --- /dev/null +++ b/components/favicon/FaviconGenerator.tsx @@ -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(null); + const [options, setOptions] = React.useState({ + 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(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 ( +
+ {/* Settings Column */} +
+ + + + + App Details + + + Configure how your app appears on devices + + + +
+ + setOptions({ ...options, name: e.target.value })} + placeholder="e.g. My Awesome Website" + /> +
+
+ + setOptions({ ...options, shortName: e.target.value })} + placeholder="e.g. My App" + /> +

Used for mobile home screen labels

+
+
+
+ + + + + + Theme Colors + + + +
+
+ +
+ setOptions({ ...options, backgroundColor: e.target.value })} + /> + setOptions({ ...options, backgroundColor: e.target.value })} + /> +
+
+
+ +
+ setOptions({ ...options, themeColor: e.target.value })} + /> + setOptions({ ...options, themeColor: e.target.value })} + /> +
+
+
+
+
+ + + + setSourceFile(null)} + disabled={isGenerating} + /> + + {result && ( + + )} + + +
+ + {/* Results Column */} +
+ {!result && !isGenerating ? ( + + +

Ready to generate

+

Upload a square image (PNG or SVG recommended) and configure your app details to get started.

+
+ ) : isGenerating ? ( + +
+
+ +
+
+
+ Processing Icons + {progress}% +
+ +
+ + ) : ( +
+
+

Generated Assets

+ +
+ + + + + + Icons + + + + HTML Code + + + + Manifest + + + + +
+ {result?.icons.map((icon) => ( + +
+
+ {icon.previewUrl && ( + {icon.name} + )} +
+
+

+ {icon.name} +

+

+ {icon.width}x{icon.height} • {(icon.size / 1024).toFixed(1)} KB +

+
+
+
+ ))} +
+
+ + +
+ + {result && } +
+
+

+ Note: Make sure to place the generated files in your website's root directory or update the href paths accordingly. +

+
+
+ + +
+ + {result && } +
+
+
+
+ )} +
+
+ ); +} diff --git a/components/layout/AppSidebar.tsx b/components/layout/AppSidebar.tsx index 11847ae..da486f9 100644 --- a/components/layout/AppSidebar.tsx +++ b/components/layout/AppSidebar.tsx @@ -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: }, + { + title: 'Favicon Generator', + href: '/favicon', + icon: + }, ] } ]; diff --git a/components/providers/Providers.tsx b/components/providers/Providers.tsx index 217d0b9..bac11dc 100644 --- a/components/providers/Providers.tsx +++ b/components/providers/Providers.tsx @@ -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 ( - {children} + + {children} + diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 0000000..575c367 --- /dev/null +++ b/components/ui/dialog.tsx @@ -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) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/components/ui/label.tsx b/components/ui/label.tsx new file mode 100644 index 0000000..c144a06 --- /dev/null +++ b/components/ui/label.tsx @@ -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) { + return ( + + ) +} + +export { Label } diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx new file mode 100644 index 0000000..3f6d00e --- /dev/null +++ b/components/ui/tabs.tsx @@ -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) { + return ( + + ) +} + +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 & + VariantProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx new file mode 100644 index 0000000..76d469a --- /dev/null +++ b/components/ui/tooltip.tsx @@ -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) { + return ( + + ) +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/lib/favicon/faviconService.ts b/lib/favicon/faviconService.ts new file mode 100644 index 0000000..55bb7f2 --- /dev/null +++ b/lib/favicon/faviconService.ts @@ -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 { + 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 ` + + + + + + + `.trim(); +} diff --git a/types/favicon.ts b/types/favicon.ts new file mode 100644 index 0000000..06fe8a1 --- /dev/null +++ b/types/favicon.ts @@ -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; +}